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
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:
@@ -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>
|
||||
|
||||
|
||||
@@ -160,8 +160,12 @@ function AcceptInviteContent() {
|
||||
setState('error')
|
||||
setErrorType('AUTH_FAILED')
|
||||
} else if (result?.ok) {
|
||||
// Redirect to set-password (middleware will enforce this since mustSetPassword=true)
|
||||
window.location.href = '/set-password'
|
||||
// Let app/page.tsx route by role. Middleware will detour to
|
||||
// /set-password if the user still needs to set one (first-time
|
||||
// setup); for users who already had a password (admin-issued
|
||||
// access link, magic-login style) it'll go straight to their
|
||||
// dashboard.
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch {
|
||||
setState('error')
|
||||
|
||||
Reference in New Issue
Block a user