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