Skip to main content

alibi_api/plugins/organization/handlers/
invitation.rs

1use super::{require_session, resolve_organization_id};
2use crate::plugins::organization::types::OrganizationResponse;
3use crate::plugins::organization::types::{
4    AcceptInvitationRequest, AcceptInvitationResponse, BasicMemberResponse,
5    CancelInvitationRequest, GetInvitationQuery, GetInvitationResponse, InviteMemberRequest,
6    ListInvitationsQuery, RejectInvitationRequest, UserInvitationResponse,
7};
8use crate::plugins::organization::{
9    InvitationLimit, OrganizationConfig, OrganizationInvitationContext,
10    OrganizationInvitationCreationContext, OrganizationInvitationDelivery,
11    OrganizationInvitationDraft, OrganizationInvitationLimitContext,
12};
13use alibi_core::entity::{AuthInvitation, AuthMember, AuthOrganization, AuthSession, AuthUser};
14use alibi_core::error::{AuthError, AuthResult};
15use alibi_core::plugin::AuthContext;
16use alibi_core::types::{AuthRequest, AuthResponse, CreateInvitation, InvitationStatus};
17use alibi_core::wire::InvitationView;
18use std::collections::HashMap;
19
20impl crate::plugins::organization::OrganizationPlugin {
21    /// List pending invitations for an email through a trusted server-side call.
22    ///
23    /// This method accepts an application-authorized email without a session.
24    /// The HTTP endpoint instead derives the email from a verified session and
25    /// rejects client email selectors.
26    ///
27    /// # Errors
28    ///
29    /// Returns errors from input validation, permission checks, storage, or configured organization hooks.
30    pub async fn list_user_invitations(
31        &self,
32        ctx: &AuthContext<impl alibi_core::AuthSchema>,
33        email: &str,
34    ) -> AuthResult<Vec<UserInvitationResponse<InvitationView>>> {
35        list_user_invitations_for_email_core(email, ctx).await
36    }
37}
38
39fn normalized_roles(input: &crate::plugins::organization::types::RoleInput) -> String {
40    input.joined()
41}
42
43fn requested_roles(input: &crate::plugins::organization::types::RoleInput) -> Vec<&str> {
44    input.roles()
45}
46
47///
48/// # Errors
49/// Returns an error when validation, storage, or an application callback fails.
50pub(super) fn require_verified_invitation_email<S: alibi_core::AuthSchema>(
51    user: &impl AuthUser,
52    config: &OrganizationConfig,
53    ctx: &AuthContext<S>,
54    message: &'static str,
55) -> AuthResult<()> {
56    let required = config
57        .require_email_verification_on_invitation
58        .unwrap_or(ctx.config.advanced.database.use_number_id);
59    if required && !user.email_verified() {
60        return Err(AuthError::forbidden(message));
61    }
62    Ok(())
63}
64
65// ---------------------------------------------------------------------------
66// Core functions
67// ---------------------------------------------------------------------------
68
69#[expect(
70    clippy::too_many_lines,
71    reason = "Keep invitation authorization, quota checks, and delivery callbacks in their required order"
72)]
73///
74/// # Errors
75/// Returns an error when validation, storage, or an application callback fails.
76#[expect(
77    clippy::cast_precision_loss,
78    clippy::as_conversions,
79    reason = "Source compares stored counts as ECMAScript Numbers"
80)]
81pub(in crate::plugins) async fn invite_member_core(
82    body: &InviteMemberRequest,
83    user: &impl AuthUser,
84    session: &impl AuthSession,
85    config: &OrganizationConfig,
86    ctx: &AuthContext<impl alibi_core::AuthSchema>,
87) -> AuthResult<InvitationView> {
88    let org_id =
89        resolve_organization_id(body.organization_id.as_deref(), None, session, ctx).await?;
90
91    if validator::Validate::validate(body).is_err() {
92        return Err(AuthError::Upstream {
93            status: 400,
94            code: "INVALID_EMAIL",
95            message: "Invalid email",
96        });
97    }
98    let member = ctx
99        .database
100        .get_member(&org_id, &user.id())
101        .await?
102        .ok_or_else(|| super::extension_common::org_error(400, "MEMBER_NOT_FOUND"))?;
103
104    if !super::extension_common::has_action(
105        member.role(),
106        "invitation",
107        "create",
108        config,
109        ctx,
110        &org_id,
111    )
112    .await?
113    {
114        return Err(super::extension_common::org_error(
115            403,
116            "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION",
117        ));
118    }
119
120    let roles = requested_roles(&body.role);
121
122    let mut valid_roles = vec!["owner".to_owned(), "admin".to_owned(), "member".to_owned()];
123    valid_roles.extend(
124        config
125            .roles
126            .iter()
127            .flat_map(|roles_2| roles_2.keys().cloned()),
128    );
129    if config.dynamic_access_control.enabled {
130        valid_roles.extend(
131            ctx.database
132                .list_organization_roles(&org_id)
133                .await?
134                .into_iter()
135                .map(|role| role.role),
136        );
137    }
138
139    let unknown_roles: Vec<_> = roles
140        .iter()
141        .copied()
142        .filter(|role| !valid_roles.iter().any(|valid| valid == role))
143        .collect();
144
145    if !unknown_roles.is_empty() {
146        // Upstream's invite path throws `new APIError` with the code inlined in
147        // the message and no `code` field — unlike the member path, which sets
148        // both. See organization/routes/crud-invites.ts.
149        return Err(AuthError::bad_request(format!(
150            "ROLE_NOT_FOUND: {}",
151            unknown_roles.join(", ")
152        )));
153    }
154
155    let member_is_creator = member
156        .role()
157        .split(',')
158        .map(str::trim)
159        .any(|role| role == config.effective_creator_role());
160    let invites_creator_role = roles
161        .iter()
162        .any(|role| *role == config.effective_creator_role());
163
164    if invites_creator_role && !member_is_creator {
165        return Err(AuthError::Upstream {
166            status: 403,
167            code: "YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE",
168            message: "You are not allowed to invite a user with this role",
169        });
170    }
171
172    if let Some(existing_user) = ctx.database.get_user_by_email(&body.email).await?
173        && ctx
174            .database
175            .get_member(&org_id, &existing_user.id())
176            .await?
177            .is_some()
178    {
179        return Err(AuthError::bad_request(
180            "User is already a member of this organization",
181        ));
182    }
183
184    let email = body.email.to_lowercase();
185    let existing = ctx
186        .database
187        .pending_invitation_page(&org_id, Some(&email))
188        .await?
189        .into_iter()
190        .find(|invitation| !invitation.is_expired());
191    if existing.is_some()
192        && body.resend != Some(true)
193        && !config.cancel_pending_invitations_on_reinvite
194    {
195        return Err(super::extension_common::org_error(
196            400,
197            "USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION",
198        ));
199    }
200    let organization = ctx
201        .database
202        .get_organization_by_id(&org_id)
203        .await?
204        .ok_or_else(|| super::extension_common::org_error(400, "ORGANIZATION_NOT_FOUND"))?;
205    let organization = OrganizationResponse::from_stored_organization(&organization)?;
206    let user_view = ctx.user_view(user);
207    let member_view = alibi_core::Member {
208        id: member.id().into_owned(),
209        organization_id: member.organization_id().into_owned(),
210        user_id: member.user_id().into_owned(),
211        role: member.role().to_owned(),
212        created_at: member.created_at(),
213    };
214    if let Some(existing) = existing {
215        if body.resend == Some(true) {
216            let updated = ctx
217                .database
218                .update_invitation_expiry(
219                    &existing.id,
220                    crate::plugins::organization::hooks::invitation::expiry(
221                        config.invitation_expires_in,
222                    )?,
223                )
224                .await?;
225            let invitation = ctx.invitation_view(&updated);
226            crate::plugins::organization::hooks::invitation::deliver(
227                config,
228                OrganizationInvitationDelivery {
229                    invitation: invitation.clone(),
230                    organization,
231                    inviter: member_view,
232                    user: user_view,
233                },
234                ctx,
235            )
236            .await?;
237            return Ok(invitation);
238        }
239        drop(
240            ctx.database
241                .update_invitation_status(&existing.id, InvitationStatus::Canceled)
242                .await?,
243        );
244    }
245    let limit_context = OrganizationInvitationLimitContext {
246        user: user_view.clone(),
247        organization: organization.clone(),
248        member: member_view.clone(),
249        member_user: ctx.user_view(
250            &ctx.database
251                .get_user_by_id(&member.user_id())
252                .await?
253                .ok_or_else(|| super::extension_common::org_error(400, "MEMBER_NOT_FOUND"))?,
254        ),
255    };
256    let limit = match &config.invitation_limit {
257        Some(InvitationLimit::Fixed(value)) => *value,
258        Some(InvitationLimit::Resolver(resolver)) => {
259            resolver
260                .invitation_limit(&limit_context, &alibi_core::CallbackContext::new(ctx, None))
261                .await?
262        }
263        None => 100.0,
264    };
265    let pending = ctx.database.pending_invitation_page(&org_id, None).await?;
266    if pending
267        .iter()
268        .filter(|invitation| !invitation.is_expired())
269        .count() as f64
270        >= limit
271    {
272        return Err(super::extension_common::org_error(
273            403,
274            "INVITATION_LIMIT_REACHED",
275        ));
276    }
277    let requested_teams = {
278        body.team_id
279            .as_ref()
280            .map(|input| input.ids())
281            .unwrap_or_default()
282    };
283    let validate_teams = config.teams.enabled
284        && !matches!(&body.team_id, Some(crate::plugins::organization::types::TeamInput::One(id)) if id.is_empty());
285    if validate_teams && requested_teams.iter().any(|id| id.contains(',')) {
286        return Err(super::extension_common::org_error(400, "INVALID_TEAM_ID"));
287    }
288    for team_id in requested_teams.iter().filter(|_| validate_teams) {
289        drop(
290            ctx.database
291                .get_team(Some(&org_id), team_id)
292                .await?
293                .ok_or_else(|| super::extension_common::org_error(400, "TEAM_NOT_FOUND"))?,
294        );
295    }
296    for team_id in requested_teams.iter().filter(|_| {
297        validate_teams
298            && (config.teams.limit_resolver.is_some()
299                || config.teams.maximum_members_per_team.is_some())
300    }) {
301        let team = ctx
302            .database
303            .get_team(Some(&org_id), team_id)
304            .await?
305            .ok_or_else(|| super::extension_common::org_error(400, "TEAM_NOT_FOUND"))?;
306        let limit_context = crate::plugins::organization::extensions::TeamLimitContext {
307            organization_id: org_id.clone(),
308            team_id: Some(team.id.clone()),
309            session: Some(ctx.session_view(session)),
310            user: Some(ctx.user_view(user)),
311            request: alibi_core::hooks::current_request_hook_context()
312                .map(|context| context.request),
313        };
314        let maximum = match &config.teams.limit_resolver {
315            Some(resolver) => resolver.maximum_team_members(&limit_context).await?,
316            None => config.teams.maximum_members_per_team,
317        };
318        if let Some(limit) = maximum
319            && ctx.database.list_team_members(team_id).await?.len() as f64 >= limit
320        {
321            return Err(super::extension_common::org_error(
322                403,
323                "TEAM_MEMBER_LIMIT_REACHED",
324            ));
325        }
326    }
327
328    let mut draft = OrganizationInvitationDraft {
329        organization_id: org_id,
330        email,
331        role: normalized_roles(&body.role),
332        team_ids: requested_teams.iter().map(|id| (*id).to_owned()).collect(),
333        inviter_id: user.id().to_string(),
334        expires_at: None,
335        options: Default::default(),
336    };
337    if let Some(hooks) = &config.invitation_hooks
338        && let Some(patch) = hooks
339            .before_create_invitation(&OrganizationInvitationCreationContext {
340                invitation: draft.clone(),
341                inviter: user_view.clone(),
342                organization: organization.clone(),
343            })
344            .await?
345    {
346        patch.apply(&mut draft);
347    }
348    let invitation_data = CreateInvitation {
349        organization_id: draft.organization_id,
350        email: draft.email,
351        role: draft.role,
352        team_id: if draft.team_ids.is_empty() {
353            None
354        } else {
355            Some(draft.team_ids.join(","))
356        },
357        inviter_id: draft.inviter_id,
358        expires_at: match draft.expires_at {
359            Some(value) => value,
360            None => crate::plugins::organization::hooks::invitation::expiry(
361                config.invitation_expires_in,
362            )?,
363        },
364    };
365    let invitation = ctx
366        .database
367        .create_invitation_with_options(invitation_data, draft.options)
368        .await?;
369    let invitation = ctx.invitation_view(&invitation);
370    crate::plugins::organization::hooks::invitation::deliver(
371        config,
372        OrganizationInvitationDelivery {
373            invitation: invitation.clone(),
374            organization: organization.clone(),
375            inviter: member_view,
376            user: user_view.clone(),
377        },
378        ctx,
379    )
380    .await?;
381    if let Some(hooks) = &config.invitation_hooks {
382        hooks
383            .after_create_invitation(&OrganizationInvitationContext {
384                invitation: invitation.clone(),
385                user: user_view,
386                organization,
387            })
388            .await?;
389    }
390    Ok(invitation)
391}
392
393///
394/// # Errors
395/// Returns an error when validation, storage, or an application callback fails.
396pub(in crate::plugins) async fn get_invitation_core(
397    query: &GetInvitationQuery,
398    user: &impl AuthUser,
399    config: &OrganizationConfig,
400    ctx: &AuthContext<impl alibi_core::AuthSchema>,
401) -> AuthResult<GetInvitationResponse<InvitationView>> {
402    let invitation = ctx
403        .database
404        .get_invitation_by_id(&query.id)
405        .await?
406        .filter(|invitation| invitation.is_pending() && !invitation.is_expired())
407        .ok_or_else(|| AuthError::bad_request("Invitation not found!"))?;
408    if user
409        .email()
410        .is_none_or(|email| email.to_lowercase() != invitation.email().to_lowercase())
411    {
412        return Err(AuthError::forbidden(
413            "You are not the recipient of the invitation",
414        ));
415    }
416    require_verified_invitation_email(
417        user,
418        config,
419        ctx,
420        "Email verification required to view or list invitations for the session email",
421    )?;
422
423    let organization = ctx
424        .database
425        .get_organization_by_id(&invitation.organization_id())
426        .await?
427        .ok_or_else(|| AuthError::bad_request("Organization not found"))?;
428    let inviter_member = ctx
429        .database
430        .get_member(&invitation.organization_id(), &invitation.inviter_id())
431        .await?
432        .ok_or_else(|| {
433            AuthError::bad_request("Inviter is no longer a member of the organization")
434        })?;
435    let inviter = ctx
436        .database
437        .get_user_by_id(&inviter_member.user_id())
438        .await?
439        .ok_or_else(|| {
440            AuthError::bad_request("Inviter is no longer a member of the organization")
441        })?;
442
443    Ok(GetInvitationResponse {
444        invitation: ctx.invitation_view(&invitation),
445        organization_name: organization.name().to_owned(),
446        organization_slug: organization.slug().to_owned(),
447        inviter_email: inviter.email().map(str::to_owned),
448    })
449}
450
451///
452/// # Errors
453/// Returns an error when validation, storage, or an application callback fails.
454pub(in crate::plugins) async fn list_invitations_core(
455    query: &ListInvitationsQuery,
456    user: &impl AuthUser,
457    session: &impl AuthSession,
458    ctx: &AuthContext<impl alibi_core::AuthSchema>,
459) -> AuthResult<Vec<InvitationView>> {
460    let org_id =
461        resolve_organization_id(query.organization_id.as_deref(), None, session, ctx).await?;
462
463    drop(
464        ctx.database
465            .get_member(&org_id, &user.id())
466            .await?
467            .ok_or_else(|| AuthError::forbidden("Not a member of this organization"))?,
468    );
469
470    let invitations = ctx.database.list_organization_invitations(&org_id).await?;
471    Ok(invitations
472        .iter()
473        .map(|invitation| ctx.invitation_view(invitation))
474        .collect())
475}
476
477///
478/// # Errors
479/// Returns an error when validation, storage, or an application callback fails.
480pub(in crate::plugins) async fn list_user_invitations_core(
481    user: &impl AuthUser,
482    ctx: &AuthContext<impl alibi_core::AuthSchema>,
483) -> AuthResult<Vec<UserInvitationResponse<InvitationView>>> {
484    // Upstream refuses to list invitations for a session whose email is not
485    // verified, so an unverified address cannot enumerate what it was invited to.
486    if !user.email_verified() {
487        return Err(AuthError::forbidden(
488            "Email verification required to view or list invitations for the session email",
489        ));
490    }
491
492    list_user_invitations_for_email_core(user.email().unwrap_or_default(), ctx).await
493}
494
495pub(in crate::plugins::organization) async fn list_user_invitations_for_email_core(
496    email: &str,
497    ctx: &AuthContext<impl alibi_core::AuthSchema>,
498) -> AuthResult<Vec<UserInvitationResponse<InvitationView>>> {
499    if email.is_empty() {
500        return Err(AuthError::bad_request(
501            "Missing session headers, or email query parameter.",
502        ));
503    }
504    // The adapter's configured page limit applies before pending-state filtering.
505    let invitations = ctx.database.list_user_invitations(email).await?;
506    let pending = invitations
507        .iter()
508        .filter(|invitation| invitation.status() == &InvitationStatus::Pending)
509        .collect::<Vec<_>>();
510    let organization_ids = pending
511        .iter()
512        .map(|invitation| invitation.organization_id().into_owned())
513        .collect::<Vec<_>>();
514    let organizations_by_id = ctx
515        .database
516        .list_organizations_by_ids(&organization_ids)
517        .await?
518        .into_iter()
519        .map(|organization| (organization.id.clone(), organization))
520        .collect::<HashMap<_, _>>();
521    Ok(pending
522        .into_iter()
523        .map(|invitation| UserInvitationResponse {
524            invitation: ctx.invitation_view(invitation),
525            organization_name: organizations_by_id
526                .get(invitation.organization_id().as_ref())
527                .map(|organization| organization.name().to_owned()),
528        })
529        .collect())
530}
531
532///
533/// # Errors
534/// Returns an error when validation, storage, or an application callback fails.
535pub(in crate::plugins) async fn reject_invitation_core(
536    body: &RejectInvitationRequest,
537    user: &impl AuthUser,
538    config: &OrganizationConfig,
539    ctx: &AuthContext<impl alibi_core::AuthSchema>,
540) -> AuthResult<AcceptInvitationResponse<InvitationView, Option<BasicMemberResponse>>> {
541    let invitation = ctx
542        .database
543        .get_invitation_by_id(&body.invitation_id)
544        .await?
545        .filter(alibi_core::Invitation::is_pending)
546        .ok_or(AuthError::Upstream {
547            status: 400,
548            code: "INVITATION_NOT_FOUND",
549            message: "Invitation not found!",
550        })?;
551
552    let user_email = user
553        .email()
554        .ok_or_else(|| AuthError::bad_request("User has no email"))?;
555
556    if invitation.email().to_lowercase() != user_email.to_lowercase() {
557        return Err(AuthError::forbidden(
558            "You are not the recipient of the invitation",
559        ));
560    }
561    require_verified_invitation_email(
562        user,
563        config,
564        ctx,
565        "Email verification required before accepting or rejecting invitation",
566    )?;
567
568    let organization = ctx
569        .database
570        .get_organization_by_id(&invitation.organization_id())
571        .await?
572        .ok_or_else(|| super::extension_common::org_error(400, "ORGANIZATION_NOT_FOUND"))?;
573    let mut hook_context = OrganizationInvitationContext {
574        invitation: ctx.invitation_view(&invitation),
575        user: ctx.user_view(user),
576        organization: OrganizationResponse::from_stored_organization(&organization)?,
577    };
578    if let Some(hooks) = &config.invitation_hooks {
579        hooks.before_reject_invitation(&hook_context).await?;
580    }
581    let updated_invitation = ctx
582        .database
583        .update_invitation_status(&invitation.id(), InvitationStatus::Rejected)
584        .await?;
585
586    hook_context.invitation = ctx.invitation_view(&updated_invitation);
587    if let Some(hooks) = &config.invitation_hooks {
588        hooks.after_reject_invitation(&hook_context).await?;
589    }
590    Ok(AcceptInvitationResponse {
591        invitation: hook_context.invitation,
592        member: None,
593    })
594}
595
596///
597/// # Errors
598/// Returns an error when validation, storage, or an application callback fails.
599pub(in crate::plugins) async fn cancel_invitation_core(
600    body: &CancelInvitationRequest,
601    user: &impl AuthUser,
602    config: &OrganizationConfig,
603    ctx: &AuthContext<impl alibi_core::AuthSchema>,
604) -> AuthResult<InvitationView> {
605    let invitation = ctx
606        .database
607        .get_invitation_by_id(&body.invitation_id)
608        .await?
609        .ok_or_else(|| super::extension_common::org_error(400, "INVITATION_NOT_FOUND"))?;
610
611    let member = ctx
612        .database
613        .get_member(invitation.organization_id().as_ref(), &user.id())
614        .await?
615        .ok_or_else(|| super::extension_common::org_error(400, "MEMBER_NOT_FOUND"))?;
616
617    if !super::extension_common::has_action(
618        member.role(),
619        "invitation",
620        "cancel",
621        config,
622        ctx,
623        invitation.organization_id().as_ref(),
624    )
625    .await?
626    {
627        return Err(AuthError::Upstream {
628            status: 403,
629            code: "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION",
630            message: "You are not allowed to cancel this invitation",
631        });
632    }
633
634    let organization = ctx
635        .database
636        .get_organization_by_id(&invitation.organization_id())
637        .await?
638        .ok_or_else(|| super::extension_common::org_error(400, "ORGANIZATION_NOT_FOUND"))?;
639    let mut hook_context = OrganizationInvitationContext {
640        invitation: ctx.invitation_view(&invitation),
641        user: ctx.user_view(user),
642        organization: OrganizationResponse::from_stored_organization(&organization)?,
643    };
644    if let Some(hooks) = &config.invitation_hooks {
645        hooks.before_cancel_invitation(&hook_context).await?;
646    }
647    let updated_invitation = ctx
648        .database
649        .update_invitation_status(&invitation.id(), InvitationStatus::Canceled)
650        .await?;
651
652    hook_context.invitation = ctx.invitation_view(&updated_invitation);
653    if let Some(hooks) = &config.invitation_hooks {
654        hooks.after_cancel_invitation(&hook_context).await?;
655    }
656    Ok(hook_context.invitation)
657}
658
659// ---------------------------------------------------------------------------
660// Handlers
661// ---------------------------------------------------------------------------
662
663///
664/// # Errors
665///
666/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
667pub async fn handle_invite_member(
668    req: &AuthRequest,
669    ctx: &AuthContext<impl alibi_core::AuthSchema>,
670    config: &OrganizationConfig,
671) -> AuthResult<AuthResponse> {
672    let body = match super::org_input::invitation_create(req) {
673        Ok(body) => body,
674        Err(response) => return Ok(response),
675    };
676    let (user, session) = super::extension_common::session(req, ctx).await?;
677    let invitation = match invite_member_core(&body, &user, &session, config, ctx).await {
678        Err(AuthError::Internal(_) | AuthError::Database(_)) => return Ok(AuthResponse::new(500)),
679        result => result?,
680    };
681    Ok(AuthResponse::json(200, &invitation)?)
682}
683
684///
685/// # Errors
686///
687/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
688pub async fn handle_get_invitation(
689    req: &AuthRequest,
690    ctx: &AuthContext<impl alibi_core::AuthSchema>,
691    config: &OrganizationConfig,
692) -> AuthResult<AuthResponse> {
693    let (user, _) = match require_session(req, ctx).await {
694        Ok(session) => session,
695        Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
696            return Ok(AuthResponse::json(
697                401,
698                &serde_json::json!({ "message": "Not authenticated" }),
699            )?);
700        }
701        Err(error) => return Err(error),
702    };
703    let query = parse_query::<GetInvitationQuery>(&req.query);
704    let response = match get_invitation_core(&query, &user, config, ctx).await {
705        // This endpoint uses a plain APIError upstream. Its not-found payload
706        // deliberately lacks the code attached by accept/reject invitation.
707        Err(AuthError::BadRequest(message)) if message == "Invitation not found!" => {
708            return Ok(AuthResponse::json(
709                400,
710                &serde_json::json!({ "message": message }),
711            )?);
712        }
713        result => result?,
714    };
715    Ok(AuthResponse::json(200, &response)?)
716}
717
718///
719/// # Errors
720///
721/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
722pub async fn handle_list_invitations(
723    req: &AuthRequest,
724    ctx: &AuthContext<impl alibi_core::AuthSchema>,
725) -> AuthResult<AuthResponse> {
726    let (user, session) = super::extension_common::session(req, ctx).await?;
727    let query = parse_query::<ListInvitationsQuery>(&req.query);
728    let invitations = list_invitations_core(&query, &user, &session, ctx).await?;
729    Ok(AuthResponse::json(200, &invitations)?)
730}
731
732///
733/// # Errors
734///
735/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
736pub async fn handle_list_user_invitations(
737    req: &AuthRequest,
738    ctx: &AuthContext<impl alibi_core::AuthSchema>,
739) -> AuthResult<AuthResponse> {
740    // An email selector is available only to trusted server-side callers.
741    if req
742        .query
743        .get("email")
744        .is_some_and(|email| !email.is_empty())
745    {
746        return Ok(AuthResponse::json(
747            400,
748            &serde_json::json!({
749                "message": "User email cannot be passed for client side API calls."
750            }),
751        )?);
752    }
753    let (user, _session) = match require_session(req, ctx).await {
754        Ok(session) => session,
755        Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {
756            return Ok(AuthResponse::json(
757                400,
758                &serde_json::json!({
759                    "message": "Missing session headers, or email query parameter."
760                }),
761            )?);
762        }
763        Err(error) => return Err(error),
764    };
765    let invitations = list_user_invitations_core(&user, ctx).await?;
766    Ok(AuthResponse::json(200, &invitations)?)
767}
768
769///
770/// # Errors
771/// Returns an error when validation, storage, or an application callback fails.
772pub async fn handle_accept_invitation(
773    req: &AuthRequest,
774    ctx: &AuthContext<impl alibi_core::AuthSchema>,
775    config: &OrganizationConfig,
776) -> AuthResult<AuthResponse> {
777    let (user, session) = super::extension_common::session(req, ctx).await?;
778    let body: AcceptInvitationRequest = match alibi_core::validate_request_body(req) {
779        Ok(value) => value,
780        Err(response) => return Ok(response),
781    };
782    let transport = super::invitation_acceptance::AcceptanceTransport::new(req);
783    let response =
784        match super::invitation_acceptance::accept(&body, &user, &session, config, ctx, &transport)
785            .await
786        {
787            Ok(response) => response,
788            Err(AuthError::Internal(_) | AuthError::Database(_)) => {
789                // The pinned HTTP boundary discards these uncaught-error cookies,
790                // while explicit application API errors retain them.
791                transport.discard_cookies();
792                return Ok(AuthResponse::new(500));
793            }
794            Err(error) => return Err(error),
795        };
796    Ok(AuthResponse::json(200, &response)?)
797}
798
799///
800/// # Errors
801///
802/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
803pub async fn handle_reject_invitation(
804    req: &AuthRequest,
805    ctx: &AuthContext<impl alibi_core::AuthSchema>,
806    config: &OrganizationConfig,
807) -> AuthResult<AuthResponse> {
808    let invitation_id = match super::org_input::invitation_id(req) {
809        Ok(id) => id,
810        Err(response) => return Ok(response),
811    };
812    let body = RejectInvitationRequest { invitation_id };
813    let (user, _session) = super::extension_common::session(req, ctx).await?;
814    let response = match reject_invitation_core(&body, &user, config, ctx).await {
815        Err(AuthError::Internal(_) | AuthError::Database(_)) => return Ok(AuthResponse::new(500)),
816        result => result?,
817    };
818    Ok(AuthResponse::json(200, &response)?)
819}
820
821///
822/// # Errors
823///
824/// Returns errors from input validation, permission checks, storage, or configured organization hooks.
825pub async fn handle_cancel_invitation(
826    req: &AuthRequest,
827    ctx: &AuthContext<impl alibi_core::AuthSchema>,
828    config: &OrganizationConfig,
829) -> AuthResult<AuthResponse> {
830    let invitation_id = match super::org_input::invitation_id(req) {
831        Ok(id) => id,
832        Err(response) => return Ok(response),
833    };
834    let body = CancelInvitationRequest { invitation_id };
835    let (user, _session) = super::extension_common::session(req, ctx).await?;
836    let response = match cancel_invitation_core(&body, &user, config, ctx).await {
837        Err(AuthError::Internal(_) | AuthError::Database(_)) => return Ok(AuthResponse::new(500)),
838        result => result?,
839    };
840    Ok(AuthResponse::json(200, &response)?)
841}
842
843fn parse_query<T: Default + serde::de::DeserializeOwned>(query: &HashMap<String, String>) -> T {
844    let json_value = serde_json::to_value(query)
845        .unwrap_or(serde_json::Value::Object(serde_json::Map::default()));
846    serde_json::from_value(json_value).unwrap_or_default()
847}