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:
@@ -48,6 +48,14 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +77,9 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
import { formatRelativeTime } from '@/lib/utils'
|
import { formatRelativeTime } from '@/lib/utils'
|
||||||
@@ -111,8 +122,45 @@ export default function MemberDetailPage() {
|
|||||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||||
const updateUser = trpc.user.update.useMutation()
|
const updateUser = trpc.user.update.useMutation()
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
|
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
|
||||||
const startImpersonation = trpc.user.startImpersonation.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)
|
// Mentor assignments (only fetched for mentors)
|
||||||
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
const { data: mentorAssignments } = trpc.mentor.listAssignments.useQuery(
|
||||||
{ mentorId: userId, page: 1, perPage: 50 },
|
{ mentorId: userId, page: 1, perPage: 50 },
|
||||||
@@ -282,6 +330,21 @@ export default function MemberDetailPage() {
|
|||||||
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleImpersonate}
|
onClick={handleImpersonate}
|
||||||
@@ -846,6 +909,67 @@ export default function MemberDetailPage() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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't paste this in a public channel. Anyone with the link
|
||||||
|
can sign in as this user until it'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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1185,6 +1185,100 @@ export const userRouter = router({
|
|||||||
return { success: true, email: user.email }
|
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
|
* Send invitation emails to multiple users
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user