From 44c7accf62d836a98ce29fabf60c0ad5ec5b270d Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 17:28:43 +0200 Subject: [PATCH] feat(admin): generate access link for users when email isn't reaching them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/(admin)/admin/members/[id]/page.tsx | 124 ++++++++++++++++++++ src/server/routers/user.ts | 94 +++++++++++++++ 2 files changed, 218 insertions(+) diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index ab38f05..d99ce47 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -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'} )} + {user.status !== 'SUSPENDED' && ( + + )} + + + + ) } diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 15687ce..28155d8 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -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 */