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:
@@ -107,6 +107,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
roles: true,
|
||||
status: true,
|
||||
inviteTokenExpiresAt: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -118,14 +119,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
return null
|
||||
}
|
||||
|
||||
// Clear token, activate user, mark as needing password
|
||||
// For users without a password, force them through /set-password.
|
||||
// For users who already have one (e.g. an admin issued a fresh
|
||||
// access link to bypass email delivery), let them keep it — this
|
||||
// is a magic-login, not a forced password reset.
|
||||
const needsPasswordSetup = !user.passwordHash
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: null,
|
||||
inviteTokenExpiresAt: null,
|
||||
status: 'ACTIVE',
|
||||
mustSetPassword: true,
|
||||
mustSetPassword: needsPasswordSetup,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
})
|
||||
@@ -137,7 +143,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
action: 'INVITATION_ACCEPTED',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email, role: user.role },
|
||||
detailsJson: { email: user.email, role: user.role, magicLogin: !needsPasswordSetup },
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -147,7 +153,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
roles: user.roles.length ? user.roles : [user.role],
|
||||
mustSetPassword: true,
|
||||
mustSetPassword: needsPasswordSetup,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user