feat(admin): generate access link for users when email isn't reaching them
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

Adds a "Copy Access Link" button on the member detail page that mints a
one-time URL the admin can share over Slack, WhatsApp, or any other
channel. Solves the "we sent them an invite three weeks ago and it
silently dropped into spam" failure mode that left jurors stranded.

Server: user.generateAccessLink (adminProcedure) inspects the target
user's state and picks the right flow:
  - INVITED / NONE / mustSetPassword / no password ever set → invite-flow
    URL (/accept-invite?token=…); the existing flow takes them through
    accept → set password → onboarding without further admin help.
  - Active user with a password → password-reset URL
    (/reset-password?token=…); they pick a new password and middleware
    bounces them to onboarding if it's still pending.

Both flows already exist; this just exposes a way to mint a fresh token
without sending an email. The token has a 24h hard expiry and is consumed
on successful completion of the flow, so a leaked or screenshot link
can't be replayed against a different user later in the day. Each
generation is audit-logged with the admin's id, the target user's id +
email, and the link kind.

UI: button next to Resend Invite on /admin/members/[id]; opens a dialog
with a read-only input pre-selected, a one-click copy button, expiry
timestamp, and a warning not to paste in public channels.

Side benefit: users like Didier who have stale JWTs from a recent role
change can use a fresh access link to force a re-login that picks up
their updated role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 17:28:43 +02:00
parent 9a9a73dde2
commit 44c7accf62
2 changed files with 218 additions and 0 deletions

View File

@@ -1185,6 +1185,100 @@ export const userRouter = router({
return { success: true, email: user.email }
}),
/**
* 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.
*
* 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.
*/
generateAccessLink: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
select: {
id: true,
email: true,
name: true,
status: true,
mustSetPassword: true,
passwordSetAt: true,
},
})
if (user.status === 'SUSPENDED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Cannot generate an access link for a suspended user',
})
}
const token = generateInviteToken()
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
let url: string
let kind: 'invite' | 'reset'
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'
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'GENERATE_ACCESS_LINK',
entityType: 'User',
entityId: user.id,
detailsJson: {
targetEmail: user.email,
targetName: user.name,
kind,
expiresAt: expiresAt.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { url, kind, expiresAt, email: user.email, name: user.name }
}),
/**
* Send invitation emails to multiple users
*/