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
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:
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user