Files
MOPC-Portal/src/app/(admin)/admin/members/[id]/page.tsx

976 lines
40 KiB
TypeScript
Raw Normal View History

'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { directSessionUpdate } from '@/lib/session-update'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TagInput } from '@/components/shared/tag-input'
import { UserActivityLog } from '@/components/shared/user-activity-log'
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
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 {
ArrowLeft,
Save,
Mail,
User,
Shield,
Loader2,
AlertCircle,
ClipboardList,
Eye,
ThumbsUp,
ThumbsDown,
Globe,
Building2,
FileText,
FolderOpen,
LogIn,
Calendar,
Clock,
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
Link as LinkIcon,
Copy,
Check,
} from 'lucide-react'
import { getCountryName, getCountryFlag } from '@/lib/countries'
import { formatRelativeTime } from '@/lib/utils'
function getRoleHomePath(role: string): string {
switch (role) {
case 'JURY_MEMBER': return '/jury'
case 'APPLICANT': return '/applicant'
case 'MENTOR': return '/mentor'
case 'OBSERVER': return '/observer'
default: return '/admin'
}
}
const statusVariant: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
ACTIVE: 'success',
SUSPENDED: 'destructive',
INVITED: 'secondary',
NONE: 'secondary',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
MENTOR: 'secondary',
OBSERVER: 'outline',
PROGRAM_ADMIN: 'default',
SUPER_ADMIN: 'default',
APPLICANT: 'secondary',
AUDIENCE: 'outline',
}
export default function MemberDetailPage() {
const params = useParams()
const router = useRouter()
const utils = trpc.useUtils()
const userId = params.id as string
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
const { data: currentUser } = trpc.user.me.useQuery()
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation()
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
const generateAccessLink = trpc.user.generateAccessLink.useMutation()
const startImpersonation = trpc.user.startImpersonation.useMutation()
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
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 },
{ enabled: user?.role === 'MENTOR' }
)
// Juror evaluations (only fetched for jury members)
const isJuror = user?.role === 'JURY_MEMBER' || user?.roles?.includes('JURY_MEMBER')
const { data: jurorEvaluations } = trpc.evaluation.getJurorEvaluations.useQuery(
{ userId },
{ enabled: !!user && !!isJuror }
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvaluation, setSelectedEvaluation] = useState<any>(null)
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<string>('JURY_MEMBER')
const [status, setStatus] = useState<string>('NONE')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
useEffect(() => {
if (user) {
setName(user.name || '')
setEmail(user.email || '')
setRole(user.role)
setStatus(user.status)
setExpertiseTags(user.expertiseTags || [])
setMaxAssignments(user.maxAssignments?.toString() || '')
setAdditionalRoles(user.roles?.filter((r: string) => r !== user.role) || [])
}
}, [user])
const handleSave = async () => {
try {
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
const allRoles = [role, ...additionalRoles.filter((r) => r !== role)] as Array<'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'APPLICANT' | 'AUDIENCE'>
await updateUser.mutateAsync({
id: userId,
email: email || undefined,
name: name || null,
role: role as typeof allRoles[number],
roles: allRoles,
status: status as 'NONE' | 'INVITED' | 'ACTIVE' | 'SUSPENDED',
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update member')
}
}
const handleSendInvitation = async () => {
try {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}
}
const handleImpersonate = async () => {
try {
const result = await startImpersonation.mutateAsync({ targetUserId: userId })
const ok = await directSessionUpdate({ impersonate: userId })
if (!ok) {
toast.error('Failed to update session for impersonation')
return
}
window.location.href = getRoleHomePath(result.targetRole)
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to start impersonation')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-32" />
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !user) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Loading Member</AlertTitle>
<AlertDescription>
{error?.message || 'The member you\'re looking for does not exist.'}
</AlertDescription>
</Alert>
<Button onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
)
}
const displayRoles = user.roles?.length ? user.roles : [user.role]
return (
<div className="space-y-6">
{/* Back nav */}
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{/* Header Hero */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<UserAvatar user={user} avatarUrl={user.avatarUrl} size="lg" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{user.name || 'Unnamed Member'}
</h1>
<p className="text-muted-foreground">{user.email}</p>
<div className="flex items-center gap-2 mt-1.5">
<Badge variant={statusVariant[user.status] || 'secondary'}>
{user.status === 'NONE' ? 'Not Invited' : user.status}
</Badge>
{displayRoles.map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'} className="text-xs">
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{(user.status === 'NONE' || user.status === 'INVITED') && (
<Button
variant="outline"
onClick={handleSendInvitation}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
{user.status === 'INVITED' ? 'Resend Invite' : 'Send Invitation'}
</Button>
)}
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
{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}
disabled={startImpersonation.isPending}
>
{startImpersonation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogIn className="mr-2 h-4 w-4" />
)}
Impersonate
</Button>
</div>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList>
<TabsTrigger value="profile">
<User className="h-4 w-4 mr-1" />
Profile
</TabsTrigger>
{isJuror && (
<TabsTrigger value="evaluations">
<ClipboardList className="h-4 w-4 mr-1" />
Evaluations
{jurorEvaluations && jurorEvaluations.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
{jurorEvaluations.length}
</Badge>
)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="profile" className="space-y-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Left column: Profile info + Projects */}
<div className="lg:col-span-2 space-y-6">
{/* Profile Details (read-only) */}
{(user.nationality || user.country || user.institution || user.bio) && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Globe className="h-4 w-4 text-blue-500" />
</div>
Profile Details
</CardTitle>
<CardDescription>Information provided during onboarding</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{user.nationality && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.nationality)}</span>
<div>
<p className="text-xs font-medium text-muted-foreground">Nationality</p>
<p className="text-sm font-medium">{getCountryName(user.nationality)}</p>
</div>
</div>
)}
{user.country && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<span className="text-xl mt-0.5 shrink-0" role="img">{getCountryFlag(user.country)}</span>
<div>
<p className="text-xs font-medium text-muted-foreground">Country of Residence</p>
<p className="text-sm font-medium">{getCountryName(user.country)}</p>
</div>
</div>
)}
{user.institution && (
<div className="flex items-start gap-3 rounded-lg border p-3">
<Building2 className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Institution / Organization</p>
<p className="text-sm font-medium">{user.institution}</p>
</div>
</div>
)}
{user.bio && (
<div className="sm:col-span-2 rounded-lg border p-3">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 mt-0.5 text-muted-foreground shrink-0" />
<div>
<p className="text-xs font-medium text-muted-foreground">Bio</p>
<p className="text-sm whitespace-pre-line mt-1">{user.bio}</p>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Projects */}
{user.teamMemberships && user.teamMemberships.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FolderOpen className="h-4 w-4 text-emerald-500" />
</div>
Projects ({user.teamMemberships.length})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="divide-y">
{user.teamMemberships.map((tm) => (
<Link
key={tm.id}
href={`/admin/projects/${tm.project.id}`}
className="flex items-center justify-between px-6 py-3 hover:bg-muted/50 transition-colors"
>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{tm.project.title}</p>
{tm.project.teamName && (
<p className="text-xs text-muted-foreground">{tm.project.teamName}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-2">
{tm.project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{tm.project.competitionCategory.replace('_', ' ')}
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{tm.role === 'LEAD' ? 'Lead' : tm.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
</Link>
))}
</div>
</CardContent>
</Card>
)}
{/* Jury Groups */}
{user.juryGroupMemberships && user.juryGroupMemberships.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Shield className="h-4 w-4 text-violet-500" />
</div>
Jury Groups ({user.juryGroupMemberships.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{user.juryGroupMemberships.map((m: { id: string; role: string; juryGroup: { id: string; name: string } }) => (
<Badge key={m.id} variant="outline" className="text-sm py-1.5 px-3">
{m.juryGroup.name}
<span className="ml-1.5 text-xs text-muted-foreground">
({m.role === 'CHAIR' ? 'Chair' : m.role === 'OBSERVER' ? 'Observer' : 'Member'})
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Mentor Assignments */}
{user.role === 'MENTOR' && mentorAssignments && mentorAssignments.assignments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-amber-500" />
</div>
Mentored Projects
</CardTitle>
<CardDescription>
{mentorAssignments.assignments.length} project{mentorAssignments.assignments.length !== 1 ? 's' : ''} assigned
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Status</TableHead>
<TableHead>Assigned</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentorAssignments.assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<Link
href={`/admin/projects/${assignment.project.id}`}
className="font-medium hover:underline"
>
{assignment.project.title}
</Link>
{assignment.project.teamName && (
<p className="text-sm text-muted-foreground">{assignment.project.teamName}</p>
)}
</TableCell>
<TableCell>
{assignment.project.competitionCategory ? (
<Badge variant="outline">{assignment.project.competitionCategory.replace('_', ' ')}</Badge>
) : '-'}
</TableCell>
<TableCell>
<Badge variant="secondary">{assignment.project.status ?? 'SUBMITTED'}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(assignment.assignedAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Activity Log */}
<UserActivityLog userId={userId} />
</div>
{/* Right sidebar: Edit form + Quick info */}
<div className="space-y-6">
{/* Quick Info Card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-slate-500/10 p-1.5">
<Clock className="h-4 w-4 text-slate-500" />
</div>
Quick Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Login</span>
<span>
{user.lastLoginAt ? (
<span title={new Date(user.lastLoginAt).toLocaleString()}>
{formatRelativeTime(user.lastLoginAt)}
</span>
) : 'Never'}
</span>
</div>
{user._count && !['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Jury Assignments</span>
<span className="font-semibold">{user._count.assignments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Mentor Assignments</span>
<span className="font-semibold">{user._count.mentorAssignments}</span>
</div>
</>
)}
</CardContent>
</Card>
{/* Status Alerts */}
{user.status === 'NONE' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Not Yet Invited</AlertTitle>
<AlertDescription>
This member was added via import but hasn&apos;t been invited yet.
</AlertDescription>
</Alert>
)}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This member hasn&apos;t accepted their invitation yet.
</AlertDescription>
</Alert>
)}
{/* Basic Info Edit */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<User className="h-4 w-4 text-blue-500" />
</div>
Edit Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={role}
onValueChange={(v) => {
if (v === 'SUPER_ADMIN') {
setPendingSuperAdminRole(true)
setShowSuperAdminConfirm(true)
} else {
setRole(v)
}
}}
disabled={!isSuperAdmin && (user.role === 'SUPER_ADMIN' || user.role === 'PROGRAM_ADMIN')}
>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdmin && <SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>}
{isSuperAdmin && <SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>}
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="APPLICANT">Applicant</SelectItem>
<SelectItem value="AUDIENCE">Audience</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Additional Roles</Label>
<p className="text-xs text-muted-foreground">
Grant additional dashboard access beyond the primary role
</p>
<div className="grid grid-cols-2 gap-2">
refactor(awards): remove AWARD_MASTER role, fold features into jury chair flow The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:21:09 +02:00
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
.filter((r) => r !== role)
.map((r) => (
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={additionalRoles.includes(r)}
onCheckedChange={(checked) => {
if (checked) {
setAdditionalRoles((prev) => [...prev, r])
} else {
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
}
}}
/>
{r.replace(/_/g, ' ')}
</label>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">Not Invited</SelectItem>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
{/* Expertise & Capacity for non-applicant */}
{!['APPLICANT', 'AUDIENCE'].includes(user.role) && (
<>
<div className="border-t pt-4 space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
</div>
</>
)}
<Button onClick={handleSave} disabled={updateUser.isPending} className="w-full">
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
{/* Evaluations Tab */}
{isJuror && (
<TabsContent value="evaluations" className="space-y-4">
{!jurorEvaluations || jurorEvaluations.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">No evaluations submitted yet</p>
</CardContent>
</Card>
) : (
(() => {
const byRound = new Map<string, typeof jurorEvaluations>()
for (const ev of jurorEvaluations) {
const key = ev.roundName
if (!byRound.has(key)) byRound.set(key, [])
byRound.get(key)!.push(ev)
}
return Array.from(byRound.entries()).map(([roundName, evals]) => (
<Card key={roundName}>
<CardHeader>
<CardTitle className="text-base">{roundName}</CardTitle>
<CardDescription>{evals.length} evaluation{evals.length !== 1 ? 's' : ''}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{evals.map((ev) => (
<TableRow key={ev.assignmentId}>
<TableCell className="font-medium">
<Link
href={`/admin/projects/${ev.projectId}`}
className="hover:underline text-primary"
>
{ev.projectTitle}
</Link>
</TableCell>
<TableCell>
{ev.evaluation.globalScore !== null && ev.evaluation.globalScore !== undefined
? <span className="font-medium">{ev.evaluation.globalScore}/10</span>
: <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell>
{ev.evaluation.binaryDecision !== null && ev.evaluation.binaryDecision !== undefined ? (
ev.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Badge variant={ev.evaluation.status === 'SUBMITTED' ? 'default' : 'secondary'}>
{ev.evaluation.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{ev.evaluation.submittedAt
? new Date(ev.evaluation.submittedAt).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedEvaluation({
...ev,
user: user,
evaluation: ev.evaluation,
})}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))
})()
)}
<EvaluationEditSheet
assignment={selectedEvaluation}
open={!!selectedEvaluation}
onOpenChange={(open) => { if (!open) setSelectedEvaluation(null) }}
onSaved={() => utils.evaluation.getJurorEvaluations.invalidate({ userId })}
/>
</TabsContent>
)}
</Tabs>
{/* Super Admin Confirmation Dialog */}
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Grant Super Admin Access?</AlertDialogTitle>
<AlertDialogDescription>
This will grant <strong>{name || user?.name || 'this user'}</strong> full Super Admin
access, including user management, system settings, and all administrative
capabilities. This action should only be performed for trusted administrators.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingSuperAdminRole(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setRole('SUPER_ADMIN')
setPendingSuperAdminRole(false)
setShowSuperAdminConfirm(false)
}}
className="bg-red-600 hover:bg-red-700"
>
Confirm Super Admin
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
feat(admin): generate access link for users when email isn't reaching them 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>
2026-05-07 17:28:43 +02:00
<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>
)
}