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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user