Skip to main content

alibi_api/plugins/organization/hooks/
invitation.rs

1use crate::plugins::organization::types::OrganizationResponse;
2use alibi_core::AuthResult;
3use alibi_core::CallbackContext;
4use alibi_core::Member;
5use alibi_core::wire::InvitationView;
6use alibi_core::wire::UserView;
7use async_trait::async_trait;
8use std::sync::Arc;
9
10#[derive(Debug, Clone)]
11pub enum InvitationLimit {
12    Fixed(f64),
13    Resolver(Arc<dyn OrganizationInvitationLimitResolver>),
14}
15#[derive(Debug, Clone)]
16pub struct OrganizationInvitationLimitContext {
17    pub user: UserView,
18    pub organization: OrganizationResponse,
19    pub member: Member,
20    /// Actual adapter-joined user, independent of the authenticated session snapshot.
21    pub member_user: UserView,
22}
23#[async_trait]
24pub trait OrganizationInvitationLimitResolver: std::fmt::Debug + Send + Sync {
25    async fn invitation_limit(
26        &self,
27        context: &OrganizationInvitationLimitContext,
28        callback: &CallbackContext,
29    ) -> AuthResult<f64>;
30}
31#[derive(Debug, Clone)]
32pub struct OrganizationInvitationDraft {
33    pub email: String,
34    pub role: String,
35    pub organization_id: String,
36    pub inviter_id: String,
37    pub team_ids: Vec<String>,
38    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
39    pub options: alibi_core::store::InvitationCreateOptions,
40}
41#[derive(Debug, Clone, Default)]
42pub struct OrganizationInvitationCreatePatch {
43    pub email: Option<String>,
44    pub role: Option<String>,
45    pub organization_id: Option<String>,
46    pub inviter_id: Option<String>,
47    pub team_ids: Option<Vec<String>>,
48    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
49    pub id: Option<String>,
50    pub status: Option<alibi_core::InvitationStatus>,
51    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
52}
53impl OrganizationInvitationCreatePatch {
54    pub(in crate::plugins::organization) fn apply(self, draft: &mut OrganizationInvitationDraft) {
55        if let Some(value) = self.email {
56            draft.email = value;
57        }
58        if let Some(value) = self.role {
59            draft.role = value;
60        }
61        if let Some(value) = self.organization_id {
62            draft.organization_id = value;
63        }
64        if let Some(value) = self.inviter_id {
65            draft.inviter_id = value;
66        }
67        if let Some(value) = self.expires_at {
68            draft.expires_at = Some(value);
69        }
70        if let Some(value) = self.id {
71            draft.options.id = Some(value);
72        }
73        if let Some(value) = self.status {
74            draft.options.status = Some(value);
75        }
76        if let Some(value) = self.created_at {
77            draft.options.created_at = Some(value);
78        }
79        if let Some(value) = self.team_ids {
80            draft.team_ids = value;
81        }
82    }
83}
84#[derive(Debug, Clone)]
85pub struct OrganizationInvitationCreationContext {
86    pub invitation: OrganizationInvitationDraft,
87    pub inviter: UserView,
88    pub organization: OrganizationResponse,
89}
90#[derive(Debug, Clone)]
91pub struct OrganizationInvitationContext {
92    pub invitation: InvitationView,
93    /// Inviter for create, recipient for reject, cancelling principal for cancel.
94    pub user: UserView,
95    pub organization: OrganizationResponse,
96}
97/// Hooks run in Source order, outside transactions. After failures retain writes.
98#[async_trait]
99pub trait OrganizationInvitationHooks: std::fmt::Debug + Send + Sync {
100    async fn before_create_invitation(
101        &self,
102        _context: &OrganizationInvitationCreationContext,
103    ) -> AuthResult<Option<OrganizationInvitationCreatePatch>> {
104        Ok(None)
105    }
106    async fn after_create_invitation(
107        &self,
108        _context: &OrganizationInvitationContext,
109    ) -> AuthResult<()> {
110        Ok(())
111    }
112    async fn before_reject_invitation(
113        &self,
114        _context: &OrganizationInvitationContext,
115    ) -> AuthResult<()> {
116        Ok(())
117    }
118    async fn after_reject_invitation(
119        &self,
120        _context: &OrganizationInvitationContext,
121    ) -> AuthResult<()> {
122        Ok(())
123    }
124    async fn before_cancel_invitation(
125        &self,
126        _context: &OrganizationInvitationContext,
127    ) -> AuthResult<()> {
128        Ok(())
129    }
130    async fn after_cancel_invitation(
131        &self,
132        _context: &OrganizationInvitationContext,
133    ) -> AuthResult<()> {
134        Ok(())
135    }
136}
137#[derive(Debug, Clone)]
138pub struct OrganizationInvitationDelivery {
139    pub invitation: InvitationView,
140    pub organization: OrganizationResponse,
141    pub inviter: Member,
142    pub user: UserView,
143}
144impl OrganizationInvitationDelivery {
145    /// Source always sends to the lowercase address, including a trusted draft override.
146    #[must_use]
147    pub fn email(&self) -> String {
148        self.invitation.email.to_lowercase()
149    }
150}
151/// Invoked with the original transport context. Delivery errors are logged and
152/// consumed by Source's runInBackgroundOrAwait; creation still runs its after hook.
153#[async_trait]
154pub trait OrganizationInvitationEmailSender: std::fmt::Debug + Send + Sync {
155    async fn send_invitation_email(
156        &self,
157        delivery: &OrganizationInvitationDelivery,
158        callback: &CallbackContext,
159    ) -> AuthResult<()>;
160}
161
162pub(in crate::plugins::organization) async fn deliver<S: alibi_core::AuthSchema>(
163    config: &crate::plugins::organization::OrganizationConfig,
164    delivery: OrganizationInvitationDelivery,
165    ctx: &alibi_core::AuthContext<S>,
166) -> AuthResult<()> {
167    let Some(sender) = config.send_invitation_email.clone() else {
168        return Ok(());
169    };
170    let callback = CallbackContext::new(ctx, None);
171    let work = async move {
172        if let Err(error) = sender.send_invitation_email(&delivery, &callback).await {
173            tracing::error!(%error, "Failed to run background task");
174        }
175        Ok(())
176    };
177    if let Some(handler) = &ctx.config.background_tasks {
178        let completion = alibi_core::start_background_task(work).await?;
179        if let Err(error) = handler.handle(completion) {
180            tracing::error!(%error, "Failed to run background task");
181        }
182    } else {
183        work.await?;
184    }
185    Ok(())
186}
187
188#[expect(
189    clippy::as_conversions,
190    clippy::cast_possible_truncation,
191    clippy::cast_precision_loss,
192    reason = "ECMAScript Date truncates the absolute millisecond timestamp"
193)]
194pub(in crate::plugins::organization) fn expiry(
195    seconds: Option<f64>,
196) -> AuthResult<chrono::DateTime<chrono::Utc>> {
197    let span = seconds
198        .filter(|value| crate::plugins::organization::policy::truthy_number(*value))
199        .unwrap_or(172_800.0);
200    let milliseconds = chrono::Utc::now().timestamp_millis() as f64 + span * 1000.0;
201    // ECMAScript TimeClip bounds. Rust dates cannot represent Invalid Date.
202    if !milliseconds.is_finite() || milliseconds.abs() > 8_640_000_000_000_000.0 {
203        return Err(alibi_core::AuthError::Config(
204            "Invitation expiry is outside the representable date range".into(),
205        ));
206    }
207    chrono::DateTime::from_timestamp_millis(milliseconds as i64).ok_or_else(|| {
208        alibi_core::AuthError::Config(
209            "Invitation expiry is outside the representable date range".into(),
210        )
211    })
212}
213
214#[derive(Debug, Clone)]
215pub struct OrganizationInvitationAcceptanceContext {
216    pub invitation: InvitationView,
217    pub user: UserView,
218    /// The originally looked-up organization, including its stored metadata.
219    pub organization: OrganizationResponse,
220}
221
222#[derive(Debug, Clone)]
223pub struct OrganizationInvitationAcceptedContext {
224    pub invitation: InvitationView,
225    pub member: Member,
226    pub user: UserView,
227    pub organization: OrganizationResponse,
228}
229
230/// Before errors prevent the conditional claim.
231///
232/// After errors retain accepted
233/// status and committed memberships/session scope. Returned JavaScript callback
234/// values do not patch acceptance; immutable snapshots express this contract.
235#[async_trait]
236pub trait OrganizationInvitationAcceptanceHooks: std::fmt::Debug + Send + Sync {
237    async fn before_accept_invitation(
238        &self,
239        _context: &OrganizationInvitationAcceptanceContext,
240    ) -> AuthResult<()> {
241        Ok(())
242    }
243    async fn after_accept_invitation(
244        &self,
245        _context: &OrganizationInvitationAcceptedContext,
246    ) -> AuthResult<()> {
247        Ok(())
248    }
249}