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>
)
}