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 [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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,38 +1228,19 @@ 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
|
await ctx.prisma.user.update({
|
||||||
let kind: 'invite' | 'reset'
|
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) {
|
const url = `${baseUrl}/accept-invite?token=${token}`
|
||||||
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
await logAudit({
|
await logAudit({
|
||||||
prisma: ctx.prisma,
|
prisma: ctx.prisma,
|
||||||
|
|||||||
Reference in New Issue
Block a user