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')
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1189,17 +1189,19 @@ export const userRouter = router({
|
||||
* 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.
|
||||
*
|
||||
* Picks the right flow based on the user's state:
|
||||
* - INVITED / mustSetPassword / no password ever set → invite-flow URL
|
||||
* (`/accept-invite?token=…`); the existing flow walks them through
|
||||
* accept → set-password → onboarding without further help.
|
||||
* - Active user with a password → password-reset URL
|
||||
* (`/reset-password?token=…`); they choose a new password and the
|
||||
* middleware bounces them to onboarding if they haven't finished.
|
||||
* One URL shape (`/accept-invite?token=…`) handles every case because the
|
||||
* credentials provider in auth.ts now branches on whether the user has a
|
||||
* password:
|
||||
* - User has no password yet (INVITED / mustSetPassword) → flow walks
|
||||
* them through accept → /set-password → onboarding.
|
||||
* - User already has a password → magic-login: token consumed, JWT
|
||||
* 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
|
||||
* the first time the user completes the flow (so leaked or screenshot
|
||||
* links can't be replayed). Audit-logged with the admin's id.
|
||||
* on first successful sign-in — leaked or screenshot links can't be
|
||||
* 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
|
||||
.input(z.object({ userId: z.string() }))
|
||||
@@ -1211,8 +1213,7 @@ export const userRouter = router({
|
||||
email: true,
|
||||
name: true,
|
||||
status: true,
|
||||
mustSetPassword: true,
|
||||
passwordSetAt: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1227,38 +1228,19 @@ export const userRouter = router({
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24h
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||
|
||||
const needsInvite =
|
||||
user.status === 'INVITED' ||
|
||||
user.status === 'NONE' ||
|
||||
user.mustSetPassword ||
|
||||
!user.passwordSetAt
|
||||
const kind: 'setup' | 'magic_login' = user.passwordHash ? 'magic_login' : 'setup'
|
||||
|
||||
let url: string
|
||||
let kind: 'invite' | 'reset'
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: expiresAt,
|
||||
// Bump NONE → INVITED so the accept-invite credentials provider works
|
||||
...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (needsInvite) {
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: expiresAt,
|
||||
// Bump NONE → INVITED so the accept-invite credentials provider works
|
||||
...(user.status === 'NONE' ? { status: 'INVITED' as const } : {}),
|
||||
},
|
||||
})
|
||||
url = `${baseUrl}/accept-invite?token=${token}`
|
||||
kind = 'invite'
|
||||
} else {
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordResetToken: token,
|
||||
passwordResetExpiresAt: expiresAt,
|
||||
},
|
||||
})
|
||||
url = `${baseUrl}/reset-password?token=${token}`
|
||||
kind = 'reset'
|
||||
}
|
||||
const url = `${baseUrl}/accept-invite?token=${token}`
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
|
||||
Reference in New Issue
Block a user