feat(v1.1.8): invitations flow (migration 0030 + accept_invite returns session)
migration 0030: app_user_invitations table — surrogate id PK + unique token_hash, app_id FK cascading, pre-stages email + display_name + roles for a user that doesn't exist yet. One-shot via atomic UPDATE SET accepted_at = NOW() WHERE accepted_at IS NULL. UsersServiceImpl gains invitations: Arc<dyn AppUserInvitationRepo> plus a mint_session() helper factored from login() and reused by accept_invite(). users::invite(email, opts) is gated on AppUsersAdmin (per brief — the most senior of the three new capabilities). Optional EmailTemplateOpts inside InviteOpts: omitting the template skips the email send so an admin can stamp invitations for out-of-band delivery (mailers, printed onboarding letters, etc.). If the template is present and the email service isn't configured, surfaces as NotConfigured; non-NotConfigured failures are logged but kept silent so the invitation row remains valid for retry. users::accept_invite(token, password, display_name?) atomically consumes the invitation, validates the new password, creates the user (returning () on DuplicateEmail — sign-up beat acceptance, they'll log in normally), and mints a fresh session via mint_session so the caller can return both the user and a working session token in one round trip. Pre-staged roles are stored on the invitation row but not yet applied — the app_user_roles table arrives in commit 8 (migration 0031). For commit 7 the staged-but-not-applied case logs an info record so an operator can audit the gap. list_invitations + revoke_invitation (admin-mediated, gated on AppUsersAdmin) ship in this commit and become reachable from the HTTP surface later in the series. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ use picloud_shared::{
|
||||
UsersListOpts, UsersListPage, UsersService,
|
||||
};
|
||||
|
||||
use crate::app_user_invitation_repo::{AppUserInvitationRepo, AppUserInvitationRepoError};
|
||||
use crate::app_user_password_reset_repo::{
|
||||
AppUserPasswordResetRepo, AppUserPasswordResetRepoError,
|
||||
};
|
||||
@@ -127,6 +128,7 @@ pub struct UsersServiceImpl {
|
||||
sessions: Arc<dyn AppUserSessionRepository>,
|
||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
||||
invitations: Arc<dyn AppUserInvitationRepo>,
|
||||
email: Arc<dyn EmailService>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
@@ -141,6 +143,7 @@ impl UsersServiceImpl {
|
||||
sessions: Arc<dyn AppUserSessionRepository>,
|
||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||
password_resets: Arc<dyn AppUserPasswordResetRepo>,
|
||||
invitations: Arc<dyn AppUserInvitationRepo>,
|
||||
email: Arc<dyn EmailService>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
@@ -151,6 +154,7 @@ impl UsersServiceImpl {
|
||||
sessions,
|
||||
verifications,
|
||||
password_resets,
|
||||
invitations,
|
||||
email,
|
||||
authz,
|
||||
events,
|
||||
@@ -287,6 +291,14 @@ impl From<AppUserPasswordResetRepoError> for UsersError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppUserInvitationRepoError> for UsersError {
|
||||
fn from(e: AppUserInvitationRepoError) -> Self {
|
||||
match e {
|
||||
AppUserInvitationRepoError::Db(err) => UsersError::Backend(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_email_error(e: EmailError) -> UsersError {
|
||||
match e {
|
||||
EmailError::NotConfigured => UsersError::EmailNotConfigured,
|
||||
@@ -698,20 +710,125 @@ impl UsersService for UsersServiceImpl {
|
||||
}
|
||||
async fn invite(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_email: &str,
|
||||
_opts: InviteOpts,
|
||||
cx: &SdkCallCx,
|
||||
email: &str,
|
||||
opts: InviteOpts,
|
||||
) -> Result<(), UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
self.require(cx.principal.as_ref(), Capability::AppUsersAdmin(cx.app_id))
|
||||
.await?;
|
||||
let normalized = validate_email(email)?;
|
||||
// Roles are stored verbatim; commit 8 (per-app roles) is what
|
||||
// actually applies them on accept. v1.1.8 ships with empty
|
||||
// role lists or whatever the script supplies — the storage
|
||||
// round-trip is identical either way.
|
||||
let display_name = validate_display_name(opts.display_name.as_deref())?;
|
||||
let token = generate_session_token();
|
||||
let expires_at = Utc::now()
|
||||
+ chrono::Duration::from_std(self.config.invitation_ttl)
|
||||
.map_err(|e| UsersError::Backend(format!("invitation ttl: {e}")))?;
|
||||
let row = self
|
||||
.invitations
|
||||
.create(
|
||||
cx.app_id,
|
||||
&normalized,
|
||||
display_name.as_deref(),
|
||||
&opts.roles,
|
||||
&token.hash,
|
||||
expires_at,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send the invitation email if a template was supplied.
|
||||
// Inviters can skip the email by omitting the template — useful
|
||||
// when the invitation flow is driven by an external delivery
|
||||
// (e.g., printed onboarding letter).
|
||||
if let Some(template) = opts.template {
|
||||
let link = build_link(&template.link_base, &token.raw);
|
||||
let body = template.body_template.replace("{link}", &link);
|
||||
let outbound = OutboundEmail {
|
||||
to: vec![normalized.clone()],
|
||||
from: template.from,
|
||||
subject: template.subject,
|
||||
text: Some(body),
|
||||
..Default::default()
|
||||
};
|
||||
let send_cx = internal_cx(cx);
|
||||
if let Err(e) = self.email.send(&send_cx, outbound).await {
|
||||
if matches!(e, EmailError::NotConfigured) {
|
||||
return Err(UsersError::EmailNotConfigured);
|
||||
}
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
invite_id = %row.id,
|
||||
app_id = %cx.app_id,
|
||||
"invite email send failed; invitation row remains valid"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept_invite(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_token: &str,
|
||||
_password: &str,
|
||||
_display_name: Option<String>,
|
||||
cx: &SdkCallCx,
|
||||
token: &str,
|
||||
password: &str,
|
||||
display_name: Option<String>,
|
||||
) -> Result<Option<GeneratedAccept>, UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
// No require: the invitation token IS the authorization.
|
||||
validate_password(password)?;
|
||||
let token_hash = hash_token(token);
|
||||
let Some(consumed) = self.invitations.consume_by_token(cx.app_id, &token_hash).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Display name on the new user: caller override beats the
|
||||
// invitation's pre-staged value beats None.
|
||||
let final_display = display_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(consumed.display_name);
|
||||
|
||||
let password_hash = hash_password(password)
|
||||
.map_err(|e| UsersError::Backend(format!("password hashing failed: {e}")))?;
|
||||
let row = match self
|
||||
.users
|
||||
.create(
|
||||
cx.app_id,
|
||||
&consumed.email,
|
||||
&password_hash,
|
||||
final_display.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(row) => row,
|
||||
Err(AppUserRepositoryError::DuplicateEmail(_)) => {
|
||||
// User already exists (sign-up beat acceptance). The
|
||||
// invitation is consumed — they'll need to log in
|
||||
// normally with their existing password.
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
// Commit 8 wires the role-application path; in commit 7 the
|
||||
// staged roles are stored on the invitation row but not
|
||||
// applied (the role table doesn't exist yet). Logged so an
|
||||
// operator can audit the gap.
|
||||
if !consumed.roles.is_empty() {
|
||||
tracing::info!(
|
||||
user_id = %row.id,
|
||||
app_id = %cx.app_id,
|
||||
roles = ?consumed.roles,
|
||||
"accept_invite: pre-staged roles will be applied once migration 0031 lands"
|
||||
);
|
||||
}
|
||||
let user_id = row.id;
|
||||
let user = Self::to_app_user(row, Vec::new());
|
||||
let session = self.mint_session(cx.app_id, user_id, &user).await?;
|
||||
self.emit(cx, "create", user_id).await;
|
||||
Ok(Some(GeneratedAccept { user, session }))
|
||||
}
|
||||
async fn add_role(
|
||||
&self,
|
||||
@@ -755,22 +872,69 @@ impl UsersService for UsersServiceImpl {
|
||||
}
|
||||
async fn list_invitations(
|
||||
&self,
|
||||
_principal: &Principal,
|
||||
_app_id: AppId,
|
||||
principal: &Principal,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Invitation>, UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
self.require(Some(principal), Capability::AppUsersAdmin(app_id))
|
||||
.await?;
|
||||
let rows = self.invitations.list_pending(app_id).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| Invitation {
|
||||
id: r.id,
|
||||
app_id: r.app_id,
|
||||
email: r.email,
|
||||
display_name: r.display_name,
|
||||
roles: r.roles,
|
||||
created_at: r.created_at,
|
||||
expires_at: r.expires_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
async fn revoke_invitation(
|
||||
&self,
|
||||
_principal: &Principal,
|
||||
_app_id: AppId,
|
||||
_invite_id: InvitationId,
|
||||
principal: &Principal,
|
||||
app_id: AppId,
|
||||
invite_id: InvitationId,
|
||||
) -> Result<bool, UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
self.require(Some(principal), Capability::AppUsersAdmin(app_id))
|
||||
.await?;
|
||||
Ok(self.invitations.revoke(app_id, invite_id).await?)
|
||||
}
|
||||
}
|
||||
|
||||
impl UsersServiceImpl {
|
||||
/// Mint a session for an already-authenticated user. Used by
|
||||
/// `login` (after password verify) and `accept_invite` (after
|
||||
/// invitation consume). Returns the raw token to embed in the
|
||||
/// response; only the SHA-256 hash hits storage.
|
||||
async fn mint_session(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AppUserId,
|
||||
user: &AppUser,
|
||||
) -> Result<GeneratedSession, UsersError> {
|
||||
let now = Utc::now();
|
||||
let absolute = now
|
||||
+ chrono::Duration::from_std(self.config.session_absolute_ttl)
|
||||
.map_err(|e| UsersError::Backend(format!("absolute ttl: {e}")))?;
|
||||
let sliding = now
|
||||
+ chrono::Duration::from_std(self.config.session_ttl)
|
||||
.map_err(|e| UsersError::Backend(format!("sliding ttl: {e}")))?;
|
||||
let expires_at = sliding.min(absolute);
|
||||
|
||||
let token = generate_session_token();
|
||||
self.sessions
|
||||
.create(app_id, user_id, &token.hash, expires_at, absolute)
|
||||
.await?;
|
||||
self.users.touch_last_login(app_id, user_id).await?;
|
||||
Ok(GeneratedSession {
|
||||
token: token.raw,
|
||||
user: user.clone(),
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared lookup logic for `verify` and `verify_session_for_realtime`.
|
||||
/// Honors the absolute cap and cross-app isolation, then bumps the
|
||||
/// sliding window (capped at the absolute cap) on success.
|
||||
|
||||
Reference in New Issue
Block a user