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:
MechaCat02
2026-06-06 12:10:32 +02:00
parent 45242e2d92
commit b07382e64b
5 changed files with 440 additions and 19 deletions

View File

@@ -21,9 +21,10 @@ use picloud_manager_core::{
FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserPasswordResetRepo,
PostgresAppUserRepository, PostgresAppUserSessionRepository,
PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService,
PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo,
PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository,
PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver,
@@ -255,11 +256,14 @@ pub async fn build_app(
Arc::new(PostgresAppUserVerificationRepo::new(pool.clone()));
let app_user_password_resets_repo =
Arc::new(PostgresAppUserPasswordResetRepo::new(pool.clone()));
let app_user_invitations_repo =
Arc::new(PostgresAppUserInvitationRepo::new(pool.clone()));
let users: Arc<dyn UsersService> = Arc::new(UsersServiceImpl::new(
app_users_repo.clone(),
app_user_sessions_repo.clone(),
app_user_verifications_repo.clone(),
app_user_password_resets_repo.clone(),
app_user_invitations_repo.clone(),
email.clone(),
authz.clone(),
events.clone(),