Skip to main content

alibi_plugins/organization/handlers/
invitation.rs

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