Skip to main content

alibi_api/plugins/organization/handlers/
team.rs

1use super::super::extensions::{TeamHookContext, TeamLimitContext};
2use super::super::{OrganizationConfig, OrganizationPlugin};
3use super::extension_common::{has_action, org_error, session};
4use alibi_core::entity::AuthUser;
5use alibi_core::types::{AddTeamMemberResult, CreateTeam, Team, UpdateTeam};
6use alibi_core::wire::{SessionView, UserView};
7use alibi_core::{AuthContext, AuthRequest, AuthResponse, AuthResult, AuthSchema, HttpMethod};
8use serde::{Deserialize, Serialize};
9use validator::Validate;
10
11#[serde_with::skip_serializing_none]
12#[derive(Debug, Deserialize, Serialize, Validate)]
13#[serde(rename_all = "camelCase")]
14pub struct CreateTeamRequest {
15    pub name: String,
16    pub organization_id: Option<String>,
17}
18
19#[serde_with::skip_serializing_none]
20#[derive(Debug, Deserialize, Serialize, Validate)]
21#[serde(rename_all = "camelCase")]
22pub struct RemoveTeamRequest {
23    pub team_id: String,
24    pub organization_id: Option<String>,
25}
26
27#[serde_with::skip_serializing_none]
28#[derive(Debug, Deserialize, Serialize, Validate)]
29#[serde(rename_all = "camelCase")]
30pub struct UpdateTeamRequest {
31    pub team_id: String,
32    pub data: UpdateTeamData,
33}
34
35#[serde_with::skip_serializing_none]
36#[derive(Debug, Deserialize, Serialize, Validate)]
37#[serde(rename_all = "camelCase")]
38pub struct UpdateTeamData {
39    #[validate(length(min = 1))]
40    pub name: Option<String>,
41    pub organization_id: Option<String>,
42}
43
44#[serde_with::skip_serializing_none]
45#[derive(Debug, Deserialize, Serialize, Validate)]
46#[serde(rename_all = "camelCase")]
47pub struct TeamMemberRequest {
48    pub team_id: String,
49    #[serde(
50        default = "super::super::types::undefined_string",
51        deserialize_with = "super::super::types::deserialize_coercible_string"
52    )]
53    pub user_id: String,
54    pub organization_id: Option<String>,
55}
56
57#[serde_with::skip_serializing_none]
58#[derive(Debug, Deserialize, Serialize, Validate)]
59pub struct SetActiveTeamRequest {
60    #[serde(
61        default,
62        rename = "teamId",
63        deserialize_with = "super::super::types::deserialize_nullable_string_field"
64    )]
65    #[serde(skip_serializing_if = "super::super::types::NullableStringField::is_missing")]
66    pub team_id: super::super::types::NullableStringField,
67}
68
69impl OrganizationPlugin {
70    /// Controlled server-side creation; the organization is explicit and no request principal is fabricated.
71    ///
72    /// # Errors
73    ///
74    /// Returns errors from input validation, permission checks, storage, or configured organization hooks.
75    pub async fn create_team<S: AuthSchema>(
76        &self,
77        ctx: &AuthContext<S>,
78        data: CreateTeam,
79    ) -> AuthResult<Team> {
80        if !self.config.teams.enabled {
81            return Err(alibi_core::AuthError::NotImplemented(
82                "Teams are disabled".to_owned(),
83            ));
84        }
85        create_team_core(data, None, None, ctx, &self.config).await
86    }
87    /// Create a team with the actual signed-cookie principal from supplied headers.
88    /// This low-level server API enforces the same organization permissions as HTTP;
89    /// builder-wide dispatch hooks and API-key session injection require normal dispatch.
90    ///
91    /// # Errors
92    /// Returns an error if authentication, authorization, policy, hooks, or storage fail.
93    pub async fn create_team_with_headers<S: AuthSchema>(
94        &self,
95        ctx: &AuthContext<S>,
96        headers: &std::collections::HashMap<String, String>,
97        data: CreateTeam,
98    ) -> AuthResult<Team> {
99        if !self.config.teams.enabled {
100            return Err(alibi_core::AuthError::NotImplemented(
101                "Teams are disabled".to_owned(),
102            ));
103        }
104        let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/create-team");
105        resolution.headers = headers
106            .iter()
107            .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
108            .collect();
109        let (user, current) = session(&resolution, ctx).await?;
110        let user = ctx.user_view(&user);
111        create_team_core(data, Some((&user, &current)), None, ctx, &self.config).await
112    }
113
114    /// Remove a team with the actual signed-cookie principal from supplied headers.
115    /// Current active-team and organization permission checks also apply to server calls.
116    /// This low-level helper does not execute builder-wide dispatch hooks.
117    ///
118    /// # Errors
119    /// Returns an error if authentication, authorization, hooks, or storage fail.
120    pub async fn remove_team_with_headers<S: AuthSchema>(
121        &self,
122        ctx: &AuthContext<S>,
123        headers: &std::collections::HashMap<String, String>,
124        organization_id: &str,
125        team_id: &str,
126    ) -> AuthResult<()> {
127        if !self.config.teams.enabled {
128            return Err(alibi_core::AuthError::NotImplemented(
129                "Teams are disabled".to_owned(),
130            ));
131        }
132        let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/remove-team");
133        resolution.headers = headers
134            .iter()
135            .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
136            .collect();
137        let (user, current) = session(&resolution, ctx).await?;
138        let user = ctx.user_view(&user);
139        remove_team_core(
140            organization_id,
141            team_id,
142            Some((&user, &current)),
143            ctx,
144            &self.config,
145        )
146        .await
147    }
148
149    ///
150    /// # Errors
151    ///
152    /// Returns errors from input validation, permission checks, storage, or configured organization hooks.
153    pub async fn remove_team<S: AuthSchema>(
154        &self,
155        ctx: &AuthContext<S>,
156        organization_id: &str,
157        team_id: &str,
158    ) -> AuthResult<()> {
159        if !self.config.teams.enabled {
160            return Err(alibi_core::AuthError::NotImplemented(
161                "Teams are disabled".to_owned(),
162            ));
163        }
164        remove_team_core(organization_id, team_id, None, ctx, &self.config).await
165    }
166}
167
168fn org_id(explicit: Option<&str>, session: Option<&SessionView>) -> AuthResult<String> {
169    explicit
170        .filter(|id| !id.is_empty())
171        .or_else(|| {
172            let s = session?;
173            s.active_organization_id.as_deref()
174        })
175        .map(str::to_owned)
176        .ok_or_else(|| org_error(400, "NO_ACTIVE_ORGANIZATION"))
177}
178
179async fn hook_context<S: AuthSchema>(
180    ctx: &AuthContext<S>,
181    org_id: &str,
182    user: Option<UserView>,
183) -> AuthResult<TeamHookContext> {
184    let organization = ctx
185        .database
186        .get_organization_by_id(org_id)
187        .await?
188        .ok_or_else(|| org_error(400, "ORGANIZATION_NOT_FOUND"))?;
189    Ok(TeamHookContext { organization, user })
190}
191
192///
193/// # Errors
194///
195/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
196#[expect(
197    clippy::cast_precision_loss,
198    reason = "Source compares stored counts as ECMAScript Numbers"
199)]
200pub async fn create_team_core<S: AuthSchema>(
201    mut data: CreateTeam,
202    actor: Option<(&UserView, &SessionView)>,
203    request: Option<&AuthRequest>,
204    ctx: &AuthContext<S>,
205    config: &OrganizationConfig,
206) -> AuthResult<Team> {
207    if let Some((user, _)) = actor {
208        let member = ctx
209            .database
210            .get_member(&data.organization_id, &user.id)
211            .await?
212            .ok_or_else(|| {
213                org_error(
214                    403,
215                    "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION",
216                )
217            })?;
218        if !has_action(
219            &member.role,
220            "team",
221            "create",
222            config,
223            ctx,
224            &data.organization_id,
225        )
226        .await?
227        {
228            return Err(org_error(
229                403,
230                "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
231            ));
232        }
233    }
234    let limit_ctx = TeamLimitContext {
235        organization_id: data.organization_id.clone(),
236        team_id: None,
237        session: actor.map(|(_, session)| session.clone()),
238        user: actor.map(|(user, _)| user.clone()),
239        request: request.cloned(),
240    };
241    let existing_teams = ctx.database.list_teams(&data.organization_id).await?;
242    let maximum = match &config.teams.limit_resolver {
243        Some(resolver) => resolver.maximum_teams(&limit_ctx).await?,
244        None => config.teams.maximum_teams,
245    };
246    if let Some(maximum) = maximum.filter(|limit| *limit != 0.0 && !limit.is_nan())
247        && existing_teams.len() as f64 >= maximum
248    {
249        return Err(org_error(
250            400,
251            "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS",
252        ));
253    }
254    let hooks = hook_context(
255        ctx,
256        &data.organization_id,
257        actor.map(|(user, _)| user.clone()),
258    )
259    .await?;
260    // The HTTP endpoint's timestamp is chosen after the awaited policy and
261    // organization lookup, as Source constructs teamData at that point.
262    // Trusted native calls retain any explicitly supplied timestamp.
263    if request.is_some() {
264        data.updated_at = Some(chrono::Utc::now());
265    }
266    if let Some(callback) = &config.teams.hooks {
267        callback
268            .before_create(&mut data, &hooks)
269            .await
270            .map_err(super::super::extensions::team_callback_error)?;
271    }
272    let team = ctx.database.create_team(data).await?;
273    if let Some(callback) = &config.teams.hooks {
274        callback
275            .after_create(&team, &hooks)
276            .await
277            .map_err(super::super::extensions::team_callback_error)?;
278    }
279    Ok(team)
280}
281
282///
283/// # Errors
284///
285/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
286pub async fn remove_team_core<S: AuthSchema>(
287    organization_id: &str,
288    team_id: &str,
289    actor: Option<(&UserView, &SessionView)>,
290    ctx: &AuthContext<S>,
291    config: &OrganizationConfig,
292) -> AuthResult<()> {
293    if let Some((user, session)) = actor {
294        let member = ctx
295            .database
296            .get_member(organization_id, &user.id)
297            .await?
298            .ok_or_else(|| org_error(403, "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"))?;
299        if session.active_team_id.as_deref() == Some(team_id) {
300            return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"));
301        }
302        if !has_action(&member.role, "team", "delete", config, ctx, organization_id).await? {
303            return Err(org_error(
304                403,
305                "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION",
306            ));
307        }
308    }
309    let team = ctx
310        .database
311        .get_team(Some(organization_id), team_id)
312        .await?
313        .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
314    if !config.teams.allow_removing_all_teams
315        && ctx.database.list_teams(organization_id).await?.len() <= 1
316    {
317        return Err(org_error(400, "UNABLE_TO_REMOVE_LAST_TEAM"));
318    }
319    let hooks = hook_context(ctx, organization_id, actor.map(|(user, _)| user.clone())).await?;
320    if let Some(callback) = &config.teams.hooks {
321        callback
322            .before_delete(&team, &hooks)
323            .await
324            .map_err(super::super::extensions::team_callback_error)?;
325    }
326    let _ignored_delete_team = ctx.database.delete_team(organization_id, team_id).await?;
327    if let Some(callback) = &config.teams.hooks {
328        callback
329            .after_delete(&team, &hooks)
330            .await
331            .map_err(super::super::extensions::team_callback_error)?;
332    }
333    Ok(())
334}
335
336///
337/// # Errors
338///
339/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
340pub async fn handle_team_request<S: AuthSchema>(
341    req: &AuthRequest,
342    ctx: &AuthContext<S>,
343    config: &OrganizationConfig,
344) -> AuthResult<Option<AuthResponse>> {
345    let is_known = matches!(
346        (req.method(), req.path()),
347        (
348            HttpMethod::Post,
349            "/organization/create-team"
350                | "/organization/update-team"
351                | "/organization/remove-team"
352                | "/organization/set-active-team"
353                | "/organization/add-team-member"
354                | "/organization/remove-team-member"
355        ) | (
356            HttpMethod::Get,
357            "/organization/list-teams"
358                | "/organization/list-user-teams"
359                | "/organization/list-team-members"
360        )
361    );
362    if !config.teams.enabled || !is_known {
363        return Ok(None);
364    }
365    let (user, current) = session(req, ctx).await?;
366    team_core(
367        req.method(),
368        req.path(),
369        None,
370        &req.query,
371        user,
372        current,
373        Some(req),
374        true,
375        ctx,
376        config,
377    )
378    .await
379}
380
381#[expect(
382    clippy::too_many_lines,
383    reason = "Keep team endpoint dispatch and each organization ownership check adjacent to its writes"
384)]
385#[expect(
386    clippy::too_many_arguments,
387    reason = "Shared execution keeps logical input, original request metadata, authenticated authority, and installed configuration separate"
388)]
389pub(in crate::plugins::organization) async fn team_core<S: AuthSchema>(
390    method: &HttpMethod,
391    path: &str,
392    input: Option<serde_json::Value>,
393    query: &std::collections::HashMap<String, String>,
394    user: alibi_core::AuthenticatedUser<S>,
395    current: SessionView,
396    request: Option<&AuthRequest>,
397    http_input: bool,
398    ctx: &AuthContext<S>,
399    config: &OrganizationConfig,
400) -> AuthResult<Option<AuthResponse>> {
401    if !config.teams.enabled {
402        return Err(org_error(404, "TEAMS_DISABLED"));
403    }
404    let user_view = ctx.user_view(&user);
405    macro_rules! body {
406        ($ty:ty) => {
407            if let Some(request) = request.filter(|_| http_input) {
408                match alibi_core::validate_request_body::<$ty>(request) {
409                    Ok(body) => body,
410                    Err(response) => return Ok(Some(response)),
411                }
412            } else {
413                let body: $ty =
414                    serde_json::from_value(input.clone().unwrap_or(serde_json::Value::Null))
415                        .map_err(|error| crate::plugins::endpoint::validation(error.to_string()))?;
416                body.validate()
417                    .map_err(|error| crate::plugins::endpoint::validation(error.to_string()))?;
418                body
419            }
420        };
421    }
422    let response = match (method, path) {
423        (HttpMethod::Post, "/organization/create-team") => {
424            let body = body!(CreateTeamRequest);
425            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
426            let team = create_team_core(
427                CreateTeam {
428                    name: body.name,
429                    organization_id: org,
430                    updated_at: Some(chrono::Utc::now()),
431                },
432                Some((&user_view, &current)),
433                request,
434                ctx,
435                config,
436            )
437            .await?;
438            AuthResponse::json(200, &team)?
439        }
440        (HttpMethod::Post, "/organization/remove-team") => {
441            let body = body!(RemoveTeamRequest);
442            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
443            remove_team_core(
444                &org,
445                &body.team_id,
446                Some((&user_view, &current)),
447                ctx,
448                config,
449            )
450            .await?;
451            AuthResponse::json(
452                200,
453                &serde_json::json!({"message":"Team removed successfully."}),
454            )?
455        }
456        (HttpMethod::Post, "/organization/update-team") => {
457            let body = body!(UpdateTeamRequest);
458            if body.data.name.as_deref() == Some("") {
459                return Ok(Some(AuthResponse::json(
460                    400,
461                    &serde_json::json!({"code":"VALIDATION_ERROR","message":"[body.data.name] Too small: expected string to have >=1 characters"}),
462                )?));
463            }
464            let org = org_id(body.data.organization_id.as_deref(), Some(&current))?;
465            let member = ctx
466                .database
467                .get_member(&org, user.id().as_ref())
468                .await?
469                .ok_or_else(|| org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"))?;
470            if !has_action(&member.role, "team", "update", config, ctx, &org).await? {
471                return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"));
472            }
473            let team = ctx
474                .database
475                .get_team(Some(&org), &body.team_id)
476                .await?
477                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
478            let hooks = hook_context(ctx, &org, Some(user_view)).await?;
479            let mut updates = UpdateTeam {
480                name: body.data.name,
481            };
482            if let Some(callback) = &config.teams.hooks {
483                callback
484                    .before_update(&team, &mut updates, &hooks)
485                    .await
486                    .map_err(super::super::extensions::team_callback_error)?;
487            }
488            let team_2 = ctx
489                .database
490                .update_team(&org, &body.team_id, updates)
491                .await?;
492            if let Some(callback) = &config.teams.hooks {
493                callback
494                    .after_update(&team_2, &hooks)
495                    .await
496                    .map_err(super::super::extensions::team_callback_error)?;
497            }
498            AuthResponse::json(200, &team_2)?
499        }
500        (HttpMethod::Get, "/organization/list-teams") => {
501            let org = org_id(
502                query.get("organizationId").map(String::as_str),
503                Some(&current),
504            )?;
505            if ctx
506                .database
507                .get_member(&org, user.id().as_ref())
508                .await?
509                .is_none()
510            {
511                return Err(org_error(
512                    403,
513                    "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION",
514                ));
515            }
516            AuthResponse::json(200, &ctx.database.list_teams(&org).await?)?
517        }
518        (HttpMethod::Get, "/organization/list-user-teams") => {
519            let target = query
520                .get("userId")
521                .filter(|id| !id.is_empty())
522                .map_or(user_view.id.as_str(), String::as_str);
523            let explicit = query
524                .get("organizationId")
525                .filter(|id| !id.is_empty())
526                .map(String::as_str);
527            let scope = if target == user_view.id {
528                explicit.map(str::to_owned)
529            } else {
530                Some(org_id(explicit, Some(&current))?)
531            };
532            if let Some(org) = &scope {
533                let requester = ctx
534                    .database
535                    .get_member(org, &user_view.id)
536                    .await?
537                    .ok_or_else(|| org_error(403, "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION"))?;
538                if target != user_view.id {
539                    if !has_action(&requester.role, "member", "update", config, ctx, org).await? {
540                        return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"));
541                    }
542                    if ctx.database.get_member(org, target).await?.is_none() {
543                        return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"));
544                    }
545                }
546            }
547            let mut teams = Vec::new();
548            for team in ctx.database.list_user_teams(target).await? {
549                if scope
550                    .as_deref()
551                    .is_some_and(|org| team.organization_id != org)
552                {
553                    continue;
554                }
555                if ctx
556                    .database
557                    .get_member(&team.organization_id, target)
558                    .await?
559                    .is_some()
560                {
561                    teams.push(team);
562                }
563            }
564            AuthResponse::json(200, &teams)?
565        }
566        (HttpMethod::Get, "/organization/list-team-members") => {
567            let team_id = query
568                .get("teamId")
569                .filter(|id| !id.is_empty())
570                .map(String::as_str)
571                .or(current.active_team_id.as_deref())
572                .ok_or_else(|| org_error(400, "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM"))?;
573            let team = ctx
574                .database
575                .get_team(None, team_id)
576                .await?
577                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
578            if ctx
579                .database
580                .get_member(&team.organization_id, &user_view.id)
581                .await?
582                .is_none()
583                || ctx
584                    .database
585                    .get_team_member(team_id, &user_view.id)
586                    .await?
587                    .is_none()
588            {
589                return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"));
590            }
591            AuthResponse::json(200, &ctx.database.list_team_members(team_id).await?)?
592        }
593        (HttpMethod::Post, "/organization/set-active-team") => {
594            use super::super::types::NullableStringField;
595            let body = body!(SetActiveTeamRequest);
596            let team_id = match body.team_id {
597                NullableStringField::Null => None,
598                NullableStringField::Missing => current.active_team_id.clone(),
599                NullableStringField::Value(value) => {
600                    if value.is_empty() {
601                        current.active_team_id.clone()
602                    } else {
603                        Some(value)
604                    }
605                }
606            };
607            if team_id.is_none() {
608                let mut response = AuthResponse::json(200, &serde_json::Value::Null)?;
609                if current.active_team_id.is_some() {
610                    let updated = ctx
611                        .database
612                        .update_session_active_team_record(&current.token, None)
613                        .await?;
614                    response.headers.append(
615                        "set-cookie",
616                        alibi_core::utils::cookie_utils::create_session_cookie(
617                            alibi_core::AuthSession::token(&updated),
618                            &ctx.config,
619                        )?,
620                    );
621                }
622                response
623            } else {
624                let team_id = team_id
625                    .as_deref()
626                    .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
627                let org = org_id(None, Some(&current))?;
628                let team = ctx
629                    .database
630                    .get_team(Some(&org), team_id)
631                    .await?
632                    .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
633                if ctx
634                    .database
635                    .get_team_member(team_id, &user_view.id)
636                    .await?
637                    .is_none()
638                {
639                    return Err(org_error(403, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"));
640                }
641                let updated = ctx
642                    .database
643                    .update_session_active_team_record(&current.token, Some(team_id))
644                    .await?;
645                AuthResponse::json(200, &team)?.with_header(
646                    "set-cookie",
647                    alibi_core::utils::cookie_utils::create_session_cookie(
648                        alibi_core::AuthSession::token(&updated),
649                        &ctx.config,
650                    )?,
651                )
652            }
653        }
654        (
655            HttpMethod::Post,
656            "/organization/add-team-member" | "/organization/remove-team-member",
657        ) => {
658            let body = body!(TeamMemberRequest);
659            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
660            let requester = ctx
661                .database
662                .get_member(&org, &user_view.id)
663                .await?
664                .ok_or_else(|| org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"))?;
665            let add = path.ends_with("/add-team-member");
666            let action = if add { "update" } else { "delete" };
667            if !has_action(&requester.role, "member", action, config, ctx, &org).await? {
668                return Err(org_error(
669                    403,
670                    if add {
671                        "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER"
672                    } else {
673                        "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER"
674                    },
675                ));
676            }
677            if ctx
678                .database
679                .get_member(&org, &body.user_id)
680                .await?
681                .is_none()
682            {
683                return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"));
684            }
685            let team = ctx
686                .database
687                .get_team(Some(&org), &body.team_id)
688                .await?
689                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
690            let target = ctx
691                .database
692                .get_user_by_id(&body.user_id)
693                .await?
694                .ok_or_else(|| alibi_core::AuthError::bad_request("User not found"))?;
695            let target = ctx.user_view(&target);
696            let hooks = hook_context(ctx, &org, Some(user_view)).await?;
697            if add {
698                if let Some(callback) = &config.teams.hooks {
699                    callback
700                        .before_add_member(&team, &target, &hooks)
701                        .await
702                        .map_err(super::super::extensions::team_callback_error)?;
703                }
704                let limit_ctx = TeamLimitContext {
705                    organization_id: org,
706                    team_id: Some(team.id.clone()),
707                    session: Some(current),
708                    user: hooks.user.clone(),
709                    request: None,
710                };
711                let maximum = match &config.teams.limit_resolver {
712                    Some(resolver) => resolver.maximum_team_members(&limit_ctx).await?,
713                    None => config.teams.maximum_members_per_team,
714                };
715                let member = match ctx
716                    .database
717                    .add_team_member(&team.id, &body.user_id, maximum)
718                    .await?
719                {
720                    AddTeamMemberResult::Added(member) | AddTeamMemberResult::Existing(member) => {
721                        member
722                    }
723                    AddTeamMemberResult::LimitReached => {
724                        return Err(org_error(403, "TEAM_MEMBER_LIMIT_REACHED"));
725                    }
726                };
727                if let Some(callback) = &config.teams.hooks {
728                    callback
729                        .after_add_member(&member, &team, &target, &hooks)
730                        .await
731                        .map_err(super::super::extensions::team_callback_error)?;
732                }
733                AuthResponse::json(200, &member)?
734            } else {
735                let member = ctx
736                    .database
737                    .get_team_member(&team.id, &body.user_id)
738                    .await?
739                    .ok_or_else(|| org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"))?;
740                if let Some(callback) = &config.teams.hooks {
741                    callback
742                        .before_remove_member(&member, &team, &target, &hooks)
743                        .await
744                        .map_err(super::super::extensions::team_callback_error)?;
745                }
746                let _ignored_remove_team_member = ctx
747                    .database
748                    .remove_team_member(&team.id, &body.user_id)
749                    .await?;
750                if let Some(callback) = &config.teams.hooks {
751                    callback
752                        .after_remove_member(&member, &team, &target, &hooks)
753                        .await
754                        .map_err(super::super::extensions::team_callback_error)?;
755                }
756                AuthResponse::json(
757                    200,
758                    &serde_json::json!({"message":"Team member removed successfully."}),
759                )?
760            }
761        }
762        _ => return Ok(None),
763    };
764    Ok(Some(response))
765}