From 47746d79ddafd042cde3c2fdb2531120fc5f5957 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 17:35:22 +0200 Subject: [PATCH] feat(auth): admin access link doubles as magic-login for users with passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(admin)/admin/members/[id]/page.tsx | 8 +-- src/app/(auth)/accept-invite/page.tsx | 8 ++- src/lib/auth.ts | 14 +++-- src/server/routers/user.ts | 64 ++++++++------------- 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index d99ce47..f3f502c 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -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 - {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.`} diff --git a/src/app/(auth)/accept-invite/page.tsx b/src/app/(auth)/accept-invite/page.tsx index 0ee4baa..f40d993 100644 --- a/src/app/(auth)/accept-invite/page.tsx +++ b/src/app/(auth)/accept-invite/page.tsx @@ -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') diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c4ad594..d761ab6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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, } } diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 28155d8..7b7442b 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -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,