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

@@ -48,6 +48,14 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { UserAvatar } from '@/components/shared/user-avatar'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -69,6 +77,9 @@ import {
LogIn,
Calendar,
Clock,
Link as LinkIcon,
Copy,
Check,
} from 'lucide-react'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { formatRelativeTime } from '@/lib/utils'
@@ -111,8 +122,45 @@ export default function MemberDetailPage() {
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
const [accessLinkOpen, setAccessLinkOpen] = useState(false)
const [accessLink, setAccessLink] = useState<{
url: string
kind: 'invite' | 'reset'
expiresAt: Date
} | null>(null)
const [linkCopied, setLinkCopied] = useState(false)
const handleGenerateAccessLink = async () => {
try {
const result = await generateAccessLink.mutateAsync({ userId })
setAccessLink({
url: result.url,
kind: result.kind,
expiresAt: new Date(result.expiresAt),
})
setLinkCopied(false)
setAccessLinkOpen(true)
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Failed to generate access link'
)
}
}
const handleCopyAccessLink = async () => {
if (!accessLink) return
try {
await navigator.clipboard.writeText(accessLink.url)
setLinkCopied(true)
toast.success('Link copied to clipboard')
} catch {
toast.error('Could not copy — please select and copy the link manually')
}
}
// Mentor assignments (only fetched for mentors)
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
{ mentorId: userId, page: 1, perPage: 50 },
@@ -282,6 +330,21 @@ export default function MemberDetailPage() {
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button>
)}
{user.status !== 'SUSPENDED' && (
<Button
variant="outline"
onClick={handleGenerateAccessLink}
disabled={generateAccessLink.isPending}
title="Generate a one-time link to share manually if email isn't reaching them"
>
{generateAccessLink.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LinkIcon className="mr-2 h-4 w-4" />
)}
Copy Access Link
</Button>
)}
<Button
variant="outline"
onClick={handleImpersonate}
@@ -846,6 +909,67 @@ export default function MemberDetailPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={accessLinkOpen} onOpenChange={setAccessLinkOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
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.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border bg-muted/40 p-3">
<Input
readOnly
value={accessLink?.url ?? ''}
onFocus={(e) => e.currentTarget.select()}
className="font-mono text-xs bg-background"
/>
</div>
<div className="flex items-center justify-between gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 shrink-0" />
<span>
Expires {accessLink?.expiresAt.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
{' · '}consumed on first successful login
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Don&apos;t paste this in a public channel. Anyone with the link
can sign in as this user until it&apos;s consumed.
</p>
</div>
<DialogFooter className="gap-2 sm:gap-2">
<Button variant="outline" onClick={() => setAccessLinkOpen(false)}>
Close
</Button>
<Button onClick={handleCopyAccessLink}>
{linkCopied ? (
<>
<Check className="mr-2 h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
Copy link
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

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
*/