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