Inline filtering results, select-all across pages, country flags, settings RBAC, and inline role changes

- Round detail: add skeleton loading for filtering stats, inline results table
  with expandable rows, pagination, override/reinstate, CSV export, and tooltip
  on AI summaries button (removes need for separate results page)
- Projects: add select-all-across-pages with Gmail-style banner, show country
  flags with tooltip instead of country codes (table + card views), add listAllIds
  backend endpoint
- Settings: allow PROGRAM_ADMIN access to settings page, restrict infrastructure
  tabs (AI, Email, Storage, Security, Webhooks) to SUPER_ADMIN only
- Members: add inline role change via dropdown submenu in user actions, enforce
  role hierarchy (only super admins can modify admin/super-admin roles) in both
  backend and UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:07:38 +01:00
parent 5cae78fe0c
commit 5c8d22ac11
9 changed files with 1257 additions and 197 deletions

View File

@@ -42,11 +42,16 @@ const TAB_ROLES: Record<TabKey, RoleValue[] | undefined> = {
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
NONE: 'secondary',
ACTIVE: 'success',
INVITED: 'secondary',
SUSPENDED: 'destructive',
}
const statusLabels: Record<string, string> = {
NONE: 'Not Invited',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
MENTOR: 'secondary',
@@ -92,6 +97,9 @@ export function MembersContent() {
const roles = TAB_ROLES[tab]
const { data: currentUser } = trpc.user.me.useQuery()
const currentUserRole = currentUser?.role as RoleValue | undefined
const { data, isLoading } = trpc.user.list.useQuery({
roles: roles,
search: search || undefined,
@@ -216,7 +224,7 @@ export function MembersContent() {
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
{statusLabels[user.status] || user.status}
</Badge>
</TableCell>
<TableCell>
@@ -233,6 +241,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
currentUserRole={currentUserRole}
/>
</TableCell>
</TableRow>
@@ -263,7 +273,7 @@ export function MembersContent() {
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
{statusLabels[user.status] || user.status}
</Badge>
</div>
</CardHeader>
@@ -305,6 +315,8 @@ export function MembersContent() {
userId={user.id}
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
currentUserRole={currentUserRole}
/>
</CardContent>
</Card>

View File

@@ -9,6 +9,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -28,15 +31,29 @@ import {
UserCog,
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
const ROLE_LABELS: Record<Role, string> = {
SUPER_ADMIN: 'Super Admin',
PROGRAM_ADMIN: 'Program Admin',
JURY_MEMBER: 'Jury Member',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
}
interface UserActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus }: UserActionsProps) {
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
@@ -44,13 +61,40 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
const sendInvitation = trpc.user.sendInvitation.useMutation()
const deleteUser = trpc.user.delete.useMutation({
onSuccess: () => {
// Invalidate user list to refresh the members table
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
// Determine which roles can be assigned
const getAvailableRoles = (): Role[] => {
if (isSuperAdmin) {
return ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Program admins can only assign lower roles
return ['JURY_MEMBER', 'MENTOR', 'OBSERVER']
}
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
}
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
@@ -98,9 +142,31 @@ export function UserActions({ userId, userEmail, userStatus }: UserActionsProps)
Edit
</Link>
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
<Mail className="mr-2 h-4 w-4" />
{isSending ? 'Sending...' : 'Send Invite'}
@@ -147,18 +213,35 @@ interface UserMobileActionsProps {
userId: string
userEmail: string
userStatus: string
userRole: Role
currentUserRole?: Role
}
export function UserMobileActions({
userId,
userEmail,
userStatus,
userRole,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleSendInvitation = async () => {
if (userStatus !== 'INVITED') {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
toast.error('User has already accepted their invitation')
return
}
@@ -175,27 +258,46 @@ export function UserMobileActions({
}
return (
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={userStatus !== 'INVITED' || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
<div className="space-y-2 pt-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/members/${userId}`}>
<UserCog className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={handleSendInvitation}
disabled={(userStatus !== 'NONE' && userStatus !== 'INVITED') || isSending}
>
{isSending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Invite
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
)}
</div>
)
}

View File

@@ -61,9 +61,10 @@ function SettingsSkeleton() {
interface SettingsContentProps {
initialSettings: Record<string, string>
isSuperAdmin?: boolean
}
export function SettingsContent({ initialSettings }: SettingsContentProps) {
export function SettingsContent({ initialSettings, isSuperAdmin = true }: SettingsContentProps) {
// We use the initial settings passed from the server
// Forms will refetch on mutation success
@@ -168,10 +169,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
<TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
)}
<TabsTrigger value="notifications" className="gap-2 shrink-0">
<Bell className="h-4 w-4" />
Notif.
@@ -180,18 +183,22 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
<TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
)}
<TabsTrigger value="audit" className="gap-2 shrink-0">
<ShieldAlert className="h-4 w-4" />
Audit
</TabsTrigger>
<TabsTrigger value="ai" className="gap-2 shrink-0">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="ai" className="gap-2 shrink-0">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="gap-2 shrink-0">
<Tags className="h-4 w-4" />
Tags
@@ -200,10 +207,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<BarChart3 className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="storage" className="gap-2 shrink-0">
<HardDrive className="h-4 w-4" />
Storage
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="storage" className="gap-2 shrink-0">
<HardDrive className="h-4 w-4" />
Storage
</TabsTrigger>
)}
</TabsList>
<div className="lg:flex lg:gap-8">
@@ -230,10 +239,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Communication</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="email" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Mail className="h-4 w-4" />
Email
</TabsTrigger>
)}
<TabsTrigger value="notifications" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bell className="h-4 w-4" />
Notifications
@@ -247,10 +258,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Security</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="security" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Shield className="h-4 w-4" />
Security
</TabsTrigger>
)}
<TabsTrigger value="audit" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<ShieldAlert className="h-4 w-4" />
Audit
@@ -260,10 +273,12 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Features</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bot className="h-4 w-4" />
AI
</TabsTrigger>
)}
<TabsTrigger value="tags" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Tags className="h-4 w-4" />
Tags
@@ -274,35 +289,39 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</TabsTrigger>
</TabsList>
</div>
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
<TabsTrigger value="storage" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<HardDrive className="h-4 w-4" />
Storage
</TabsTrigger>
</TabsList>
</div>
{isSuperAdmin && (
<div>
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Infrastructure</p>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
<TabsTrigger value="storage" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<HardDrive className="h-4 w-4" />
Storage
</TabsTrigger>
</TabsList>
</div>
)}
</nav>
</div>
{/* Content area */}
<div className="flex-1 min-w-0">
<TabsContent value="ai" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>
Configure AI-powered features like smart jury assignment
</CardDescription>
</CardHeader>
<CardContent>
<AISettingsForm settings={aiSettings} />
</CardContent>
</Card>
<AIUsageCard />
</TabsContent>
{isSuperAdmin && (
<TabsContent value="ai" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
<CardDescription>
Configure AI-powered features like smart jury assignment
</CardDescription>
</CardHeader>
<CardContent>
<AISettingsForm settings={aiSettings} />
</CardContent>
</Card>
<AIUsageCard />
</TabsContent>
)}
<TabsContent value="tags">
<Card>
@@ -350,19 +369,21 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
<TabsContent value="email">
<Card>
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
<CardDescription>
Configure email settings for notifications and magic links
</CardDescription>
</CardHeader>
<CardContent>
<EmailSettingsForm settings={emailSettings} />
</CardContent>
</Card>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="email">
<Card>
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
<CardDescription>
Configure email settings for notifications and magic links
</CardDescription>
</CardHeader>
<CardContent>
<EmailSettingsForm settings={emailSettings} />
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="notifications">
<Card>
@@ -378,33 +399,37 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
<TabsContent value="storage">
<Card>
<CardHeader>
<CardTitle>File Storage</CardTitle>
<CardDescription>
Configure file upload limits and allowed types
</CardDescription>
</CardHeader>
<CardContent>
<StorageSettingsForm settings={storageSettings} />
</CardContent>
</Card>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="storage">
<Card>
<CardHeader>
<CardTitle>File Storage</CardTitle>
<CardDescription>
Configure file upload limits and allowed types
</CardDescription>
</CardHeader>
<CardContent>
<StorageSettingsForm settings={storageSettings} />
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>
Configure security and access control settings
</CardDescription>
</CardHeader>
<CardContent>
<SecuritySettingsForm settings={securitySettings} />
</CardContent>
</Card>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="security">
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
<CardDescription>
Configure security and access control settings
</CardDescription>
</CardHeader>
<CardContent>
<SecuritySettingsForm settings={securitySettings} />
</CardContent>
</Card>
</TabsContent>
)}
<TabsContent value="defaults">
<Card>
@@ -502,26 +527,28 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />
Webhooks
</CardTitle>
<CardDescription>
Configure webhook endpoints for platform events
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/admin/settings/webhooks">
<Webhook className="mr-2 h-4 w-4" />
Manage Webhooks
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
{isSuperAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />
Webhooks
</CardTitle>
<CardDescription>
Configure webhook endpoints for platform events
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/admin/settings/webhooks">
<Webhook className="mr-2 h-4 w-4" />
Manage Webhooks
<ExternalLink className="ml-2 h-3 w-3" />
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</>
)