use super::extension_common::org_error;
use super::invitation::require_verified_invitation_email;
use crate::organization::extensions::TeamLimitContext;
use crate::organization::types::{
AcceptInvitationRequest, AcceptInvitationResponse, BasicMemberResponse, OrganizationResponse,
};
use crate::organization::{
OrganizationConfig, OrganizationInvitationAcceptanceContext,
OrganizationInvitationAcceptedContext,
};
use alibi_core::entity::{AuthInvitation, AuthSession, AuthUser};
use alibi_core::error::codes;
use alibi_core::session::SessionRequest;
use alibi_core::store::transaction;
use alibi_core::types::AddTeamMemberResult;
use alibi_core::wire::InvitationView;
use alibi_core::{
AuthContext, AuthError, AuthRequest, AuthResult, AuthSchema, CreateMember, InvitationStatus,
};
use std::sync::Arc;
#[derive(Clone)]
pub(in crate::organization) struct AcceptanceTransport {
request: Option<AuthRequest>,
call: Option<alibi_core::endpoint::EndpointCall>,
cookie_header: Option<String>,
cookies: Arc<std::sync::Mutex<Vec<String>>>,
}
impl AcceptanceTransport {
pub(in crate::organization) fn new(request: &AuthRequest) -> Self {
Self {
request: Some(request.clone()),
call: None,
cookie_header: request.header("cookie").cloned(),
cookies: Arc::default(),
}
}
pub(in crate::organization) fn native(call: &alibi_core::endpoint::EndpointCall) -> Self {
Self {
request: None,
call: Some(call.clone()),
cookie_header: call.session_headers().get("cookie").cloned(),
cookies: Arc::default(),
}
}
fn issue_cookie(&self, value: String) {
if let Some(request) = &self.request {
request.queue_response_header("set-cookie", &value);
}
if let Some(call) = &self.call {
call.queue_response_header("set-cookie", &value);
}
self.cookies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(value);
}
pub(in crate::organization) fn discard_cookies(&self) {
let mut headers: Vec<_> = self
.request
.as_ref()
.map(AuthRequest::take_response_headers)
.or_else(|| {
self.call
.as_ref()
.map(alibi_core::endpoint::EndpointCall::take_response_headers)
})
.unwrap_or_default()
.into_iter()
.collect();
for cookie in self
.cookies
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.rev()
{
if let Some(index) = headers.iter().rposition(|(name, value)| {
name.eq_ignore_ascii_case("set-cookie") && value == cookie
}) {
_ = headers.remove(index);
}
}
for (name, value) in headers {
if let Some(request) = &self.request {
request.queue_response_header(&name, &value);
}
if let Some(call) = &self.call {
call.queue_response_header(name, value);
}
}
}
}
fn acceptance_error(status: u16, code: &'static str) -> AuthError {
let message = match code {
codes::INVITATION_NOT_FOUND => "Invitation not found",
codes::YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION => {
"You are not the recipient of the invitation"
}
codes::ORGANIZATION_MEMBERSHIP_LIMIT_REACHED => "Organization membership limit reached",
_ => return org_error(status, code),
};
AuthError::Upstream {
status,
code,
message,
}
}
#[expect(
clippy::as_conversions,
clippy::cast_precision_loss,
reason = "Preserve JavaScript Number rounding at the compatibility boundary"
)]
#[expect(
clippy::too_many_lines,
reason = "Keep invitation claims, membership transactions, and lifecycle callbacks in order"
)]
pub(in crate::organization) async fn accept<S: AuthSchema>(
body: &AcceptInvitationRequest,
user: &impl AuthUser,
session: &impl AuthSession,
config: &OrganizationConfig,
ctx: &AuthContext<S>,
transport: &AcceptanceTransport,
) -> AuthResult<AcceptInvitationResponse<InvitationView, BasicMemberResponse>> {
let invitation = ctx
.database
.get_invitation_by_id(&body.invitation_id)
.await?
.filter(|invitation| invitation.is_pending() && !invitation.is_expired())
.ok_or_else(|| acceptance_error(400, codes::INVITATION_NOT_FOUND))?;
let email = user
.email()
.ok_or_else(|| AuthError::bad_request("User has no email"))?;
if invitation.email().to_lowercase() != email.to_lowercase() {
return Err(acceptance_error(
403,
codes::YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION,
));
}
require_verified_invitation_email(
user,
config,
ctx,
"Email verification required before accepting or rejecting invitation",
)?;
let count = ctx
.database
.count_organization_members(invitation.organization_id().as_ref())
.await?;
let organization = ctx
.database
.get_organization_by_id(invitation.organization_id().as_ref())
.await?
.ok_or_else(|| acceptance_error(400, codes::ORGANIZATION_NOT_FOUND))?;
let original_user = ctx.user_view(user);
let original_organization = OrganizationResponse::from_stored_organization(&organization)?;
let limit = crate::organization::policy::admission_limit(
config.membership_limit.as_ref(),
&original_user,
&original_organization,
)
.await?;
if count as f64 >= limit {
return Err(acceptance_error(
403,
codes::ORGANIZATION_MEMBERSHIP_LIMIT_REACHED,
));
}
if let Some(hooks) = &config.invitation_acceptance_hooks {
hooks
.before_accept_invitation(&OrganizationInvitationAcceptanceContext {
invitation: ctx.invitation_view(&invitation),
user: original_user.clone(),
organization: original_organization.clone(),
})
.await?;
}
let accepted = ctx
.database
.update_invitation_status_if_status(
&body.invitation_id,
InvitationStatus::Pending,
InvitationStatus::Accepted,
)
.await?
.ok_or_else(|| acceptance_error(400, codes::INVITATION_NOT_FOUND))?;
let accepted_for_tx = accepted.clone();
let original_session = ctx.session_view(session);
let token = session.token().to_owned();
let teams = config.teams.clone();
let tx_user = original_user.clone();
let tx_transport = transport.clone();
let auth_config = Arc::clone(&ctx.config);
let result = transaction(ctx.database.as_ref(), move |tx| {
Box::pin(async move {
let team_ids: Vec<_> = if teams.enabled {
accepted_for_tx
.team_id
.as_deref()
.filter(|ids| !ids.is_empty())
.map(|ids| ids.split(',').collect())
.unwrap_or_default()
} else {
Vec::new()
};
for team_id in &team_ids {
if tx
.get_team(&accepted_for_tx.organization_id, team_id)
.await?
.is_none()
{
return Err(acceptance_error(400, codes::TEAM_NOT_FOUND));
}
let maximum = if let Some(resolver) = &teams.limit_resolver {
resolver
.maximum_team_members(&TeamLimitContext {
organization_id: accepted_for_tx.organization_id.clone(),
team_id: Some((*team_id).to_owned()),
session: Some(original_session.clone()),
user: Some(tx_user.clone()),
request: None,
})
.await?
} else {
teams.maximum_members_per_team
};
if matches!(
tx.add_team_member(team_id, &tx_user.id, maximum).await?,
AddTeamMemberResult::LimitReached
) {
return Err(acceptance_error(403, codes::TEAM_MEMBER_LIMIT_REACHED));
}
}
if team_ids.len() == 1 {
use alibi_core::utils::cookie_utils::{
create_session_cookie_with_max_age, create_session_like_cookie,
related_cookie_name, sign_cookie_value, verify_cookie_value,
};
let updated = tx
.update_session_active_team_record(&token, team_ids.first().copied())
.await?;
let preference = related_cookie_name(&auth_config, "dont_remember");
let dont_remember = tx_transport
.cookie_header
.as_deref()
.and_then(|header| {
cookie::Cookie::split_parse(header)
.flatten()
.find(|cookie| cookie.name() == preference)
.map(|cookie| cookie.value().to_owned())
})
.and_then(|value| verify_cookie_value(&value, auth_config.current_secret()))
.is_some_and(|value| !value.is_empty());
tx_transport.issue_cookie(create_session_cookie_with_max_age(
Some(updated.token()),
(!dont_remember).then(|| auth_config.session.expires_in.num_seconds()),
&auth_config,
)?);
if dont_remember {
tx_transport.issue_cookie(create_session_like_cookie(
&preference,
&sign_cookie_value("true", auth_config.current_secret()),
None,
&auth_config,
)?);
}
}
let member = tx
.create_member(CreateMember {
organization_id: accepted_for_tx.organization_id.clone(),
user_id: tx_user.id,
role: accepted_for_tx
.role
.ok_or_else(|| AuthError::bad_request("Invitation role is missing"))?,
})
.await?;
_ = tx
.update_session_active_organization_record(
&token,
Some(&accepted_for_tx.organization_id),
)
.await?;
Ok(member)
})
})
.await;
let member = match result {
Ok(member) => member,
Err(error) => {
_ = ctx
.database
.update_invitation_status_if_status(
&body.invitation_id,
InvitationStatus::Accepted,
InvitationStatus::Pending,
)
.await?;
return Err(error);
}
};
let view = ctx.invitation_view(&accepted);
if let Some(hooks) = &config.invitation_acceptance_hooks {
hooks
.after_accept_invitation(&OrganizationInvitationAcceptedContext {
invitation: view.clone(),
member: member.clone(),
user: original_user,
organization: original_organization,
})
.await?;
}
Ok(AcceptInvitationResponse {
invitation: view,
member: BasicMemberResponse::from_member(&member),
})
}