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 [accessLinkOpen, setAccessLinkOpen] = useState(false)
const [accessLink, setAccessLink] = useState<{ const [accessLink, setAccessLink] = useState<{
url: string url: string
kind: 'invite' | 'reset' kind: 'setup' | 'magic_login'
expiresAt: Date expiresAt: Date
} | null>(null) } | null>(null)
const [linkCopied, setLinkCopied] = useState(false) const [linkCopied, setLinkCopied] = useState(false)
@@ -918,9 +918,9 @@ export default function MemberDetailPage() {
Access link ready Access link ready
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{accessLink?.kind === 'invite' {accessLink?.kind === 'magic_login'
? `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} 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} so they can pick a new password. They'll land on their dashboard once done.`} : `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> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -160,8 +160,12 @@ function AcceptInviteContent() {
setState('error') setState('error')
setErrorType('AUTH_FAILED') setErrorType('AUTH_FAILED')
} else if (result?.ok) { } else if (result?.ok) {
// Redirect to set-password (middleware will enforce this since mustSetPassword=true) // Let app/page.tsx route by role. Middleware will detour to
window.location.href = '/set-password' // /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 { } catch {
setState('error') setState('error')

View File

@@ -107,6 +107,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
roles: true, roles: true,
status: true, status: true,
inviteTokenExpiresAt: true, inviteTokenExpiresAt: true,
passwordHash: true,
}, },
}) })
@@ -118,14 +119,19 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return null 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({ await prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
inviteToken: null, inviteToken: null,
inviteTokenExpiresAt: null, inviteTokenExpiresAt: null,
status: 'ACTIVE', status: 'ACTIVE',
mustSetPassword: true, mustSetPassword: needsPasswordSetup,
lastLoginAt: new Date(), lastLoginAt: new Date(),
}, },
}) })
@@ -137,7 +143,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
action: 'INVITATION_ACCEPTED', action: 'INVITATION_ACCEPTED',
entityType: 'User', entityType: 'User',
entityId: user.id, entityId: user.id,
detailsJson: { email: user.email, role: user.role }, detailsJson: { email: user.email, role: user.role, magicLogin: !needsPasswordSetup },
}, },
}).catch(() => {}) }).catch(() => {})
@@ -147,7 +153,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
name: user.name, name: user.name,
role: user.role, role: user.role,
roles: user.roles.length ? user.roles : [user.role], roles: user.roles.length ? user.roles : [user.role],
mustSetPassword: true, mustSetPassword: needsPasswordSetup,
} }
} }

View File

@@ -1189,17 +1189,19 @@ export const userRouter = router({
* Generate an access link an admin can copy and hand to the user out-of-band * Generate an access link an admin can copy and hand to the user out-of-band
* (Slack, WhatsApp, in person) when email isn't reaching them. * (Slack, WhatsApp, in person) when email isn't reaching them.
* *
* Picks the right flow based on the user's state: * One URL shape (`/accept-invite?token=…`) handles every case because the
* - INVITED / mustSetPassword / no password ever set → invite-flow URL * credentials provider in auth.ts now branches on whether the user has a
* (`/accept-invite?token=…`); the existing flow walks them through * password:
* accept → set-password → onboarding without further help. * - User has no password yet (INVITED / mustSetPassword)flow walks
* - Active user with a password → password-reset URL * them through accept → /set-password → onboarding.
* (`/reset-password?token=…`); they choose a new password and the * - User already has a password → magic-login: token consumed, JWT
* middleware bounces them to onboarding if they haven't finished. * issued with their existing role, redirected to their dashboard.
* Their password is preserved.
* *
* Each generation rotates the token, sets a 24h expiry, and is consumed * Each generation rotates the token, sets a 24h expiry, and is consumed
* the first time the user completes the flow (so leaked or screenshot * on first successful sign-in — leaked or screenshot links can't be
* links can't be replayed). Audit-logged with the admin's id. * replayed. Audit-logged with the admin's id, the target user's id +
* email, and whether it'll behave as setup or magic-login.
*/ */
generateAccessLink: adminProcedure generateAccessLink: adminProcedure
.input(z.object({ userId: z.string() })) .input(z.object({ userId: z.string() }))
@@ -1211,8 +1213,7 @@ export const userRouter = router({
email: true, email: true,
name: true, name: true,
status: true, status: true,
mustSetPassword: true, passwordHash: true,
passwordSetAt: true,
}, },
}) })
@@ -1227,16 +1228,8 @@ export const userRouter = router({
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const needsInvite = const kind: 'setup' | 'magic_login' = user.passwordHash ? 'magic_login' : 'setup'
user.status === 'INVITED' ||
user.status === 'NONE' ||
user.mustSetPassword ||
!user.passwordSetAt
let url: string
let kind: 'invite' | 'reset'
if (needsInvite) {
await ctx.prisma.user.update({ await ctx.prisma.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
@@ -1246,19 +1239,8 @@ export const userRouter = router({
...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}), ...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}),
}, },
}) })
url = `${baseUrl}/accept-invite?token=${token}`
kind = 'invite' const url = `${baseUrl}/accept-invite?token=${token}`
} else {
await ctx.prisma.user.update({
where: { id: user.id },
data: {
passwordResetToken: token,
passwordResetExpiresAt: expiresAt,
},
})
url = `${baseUrl}/reset-password?token=${token}`
kind = 'reset'
}
await logAudit({ await logAudit({
prisma: ctx.prisma, prisma: ctx.prisma,