feat(auth): admin access link doubles as magic-login for users with passwords
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s

The original generateAccessLink branched on user state and minted either
an invite URL (forces password setup) or a reset URL (forces password
change). Both required the user to set/change a password — fine for new
users, painful for tech-illiterate sponsor jurors who already have a
working password and just need a fresh login because their JWT went
stale or their email is bouncing.

This adapts the existing invite-token flow to behave as a magic-login
when the user already has a password:

  - auth.ts credentials.authorize: only set mustSetPassword=true if the
    user has no passwordHash. Users who already set one keep it, the
    invite token is consumed, JWT is issued with their current role,
    they're signed in.
  - accept-invite/page.tsx: redirect to / after accept (was hardcoded
    to /set-password). The middleware already enforces the
    /set-password detour when mustSetPassword is true, so users who
    need it still land there; everyone else routes by role.
  - generateAccessLink: drop the reset-password branch. Always emits an
    /accept-invite URL. The flow naturally adapts: setup for new users,
    magic-login for active ones. Audit log records which behavior fired
    (kind: 'setup' | 'magic_login').
  - dialog copy: clearer description for each kind.

Net behavior: Didier (active, has password, stale JWT after role
migration) clicks his link → instant login on /jury, password preserved.
Magali (no password yet) clicks hers → /set-password → onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 17:35:22 +02:00
parent 44c7accf62
commit 47746d79dd
4 changed files with 43 additions and 51 deletions

View File

@@ -128,7 +128,7 @@ export default function MemberDetailPage() {
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
const [accessLink, setAccessLink] = useState<{
url: string
kind: 'invite' | 'reset'
kind: 'setup' | 'magic_login'
expiresAt: Date
} | null>(null)
const [linkCopied, setLinkCopied] = useState(false)
@@ -918,9 +918,9 @@ export default function MemberDetailPage() {
Access link ready
</DialogTitle>
<DialogDescription>
{accessLink?.kind === 'invite'
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through accepting the invite, setting a password, and onboarding.`
: `Share this with ${user.name || user.email} so they can pick a new password. They'll land on their dashboard once done.`}
{accessLink?.kind === 'magic_login'
? `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. Clicking it will sign them in directly (their existing password is preserved) and take them to their dashboard.`
: `Share this with ${user.name || user.email} via Slack, WhatsApp, or any channel that reaches them. They'll be walked through setting a password and onboarding.`}
</DialogDescription>
</DialogHeader>