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:
29
crates/manager-core/migrations/0030_app_user_invitations.sql
Normal file
29
crates/manager-core/migrations/0030_app_user_invitations.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- v1.1.8 User Management — invitations.
|
||||
--
|
||||
-- Unlike verification + reset tokens, invitations don't carry a
|
||||
-- user_id — the user doesn't exist yet. Instead they pre-stage the
|
||||
-- email, an optional display name, and a roles array applied on
|
||||
-- accept (once the per-app role table exists in migration 0031).
|
||||
--
|
||||
-- token_hash UNIQUE is the lookup key; the surrogate `id` UUID is
|
||||
-- what the admin invitations UI references (rotation-safe; an
|
||||
-- admin can list pending invites by id without leaking tokens).
|
||||
--
|
||||
-- accepted_at gates one-shot semantics: the consume path is an
|
||||
-- atomic UPDATE WHERE accepted_at IS NULL. Stale accept attempts get
|
||||
-- nothing, so a leaked / cached token can't be replayed.
|
||||
|
||||
CREATE TABLE app_user_invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
accepted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_app_user_invitations_app_pending
|
||||
ON app_user_invitations (app_id) WHERE accepted_at IS NULL;
|
||||
Reference in New Issue
Block a user