fix(members): replace flat role checkbox grid with assigned-only dropdown + confirm modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m55s

The previous Additional Roles grid laid every role option out as a row of
checkboxes regardless of assignment, which made unchecked roles look like
roles the user already had — admins almost toggled the wrong role on the
wrong user (e.g. nearly granting JURY_MEMBER when looking at an
AWARD_MASTER).

New layout shows only the roles a user actually has, as removable badges
with an X. A "Manage roles" dropdown next to them surfaces the full role
list as DropdownMenuCheckboxItems (assigned ones are checked, the
primary role is excluded). Toggling any item opens an AlertDialog with
add/remove-specific copy that names the user and the dashboard being
granted/revoked, so the click is impossible to misread.

The change is staged into local additionalRoles state — same flow as
before — and persisted on Save. Modal copy spells this out so the admin
knows the action isn't applied until they click Save below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-05-07 18:27:15 +02:00
parent 47746d79dd
commit 3bcbf72ad6

View File

@@ -56,6 +56,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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 {
@@ -80,6 +88,8 @@ import {
Link as LinkIcon, Link as LinkIcon,
Copy, Copy,
Check, Check,
Plus,
X,
} 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'
@@ -185,6 +195,10 @@ export default function MemberDetailPage() {
const [maxAssignments, setMaxAssignments] = useState<string>('') const [maxAssignments, setMaxAssignments] = useState<string>('')
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false) const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false)
const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false) const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false)
const [pendingAdditionalRole, setPendingAdditionalRole] = useState<{
role: 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR'
action: 'add' | 'remove'
} | null>(null)
const [additionalRoles, setAdditionalRoles] = useState<string[]>([]) const [additionalRoles, setAdditionalRoles] = useState<string[]>([])
useEffect(() => { useEffect(() => {
@@ -691,26 +705,72 @@ export default function MemberDetailPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Additional Roles</Label> <Label>Additional Roles</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Grant additional dashboard access beyond the primary role Grant additional dashboard access beyond the primary role.
Click the menu to add or remove a role you&apos;ll be
asked to confirm each change.
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="flex flex-wrap items-center gap-2">
{additionalRoles.length === 0 ? (
<span className="text-sm text-muted-foreground italic">
None only the primary role above
</span>
) : (
additionalRoles.map((r) => (
<Badge
key={r}
variant={roleColors[r] || 'secondary'}
className="gap-1.5 pl-2 pr-1 py-0.5"
>
{r.replace(/_/g, ' ')}
<button
type="button"
aria-label={`Remove ${r.replace(/_/g, ' ')} role`}
className="rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
onClick={() =>
setPendingAdditionalRole({
role: r as 'JURY_MEMBER' | 'OBSERVER' | 'MENTOR',
action: 'remove',
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" type="button">
<Plus className="mr-1 h-3.5 w-3.5" />
Manage roles
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Toggle additional roles</DropdownMenuLabel>
<DropdownMenuSeparator />
{(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const) {(['JURY_MEMBER', 'OBSERVER', 'MENTOR'] as const)
.filter((r) => r !== role) .filter((r) => r !== role)
.map((r) => ( .map((r) => {
<label key={r} className="flex items-center gap-2 text-sm cursor-pointer"> const isAssigned = additionalRoles.includes(r)
<Checkbox return (
checked={additionalRoles.includes(r)} <DropdownMenuCheckboxItem
onCheckedChange={(checked) => { key={r}
if (checked) { checked={isAssigned}
setAdditionalRoles((prev) => [...prev, r]) onSelect={(e) => {
} else { e.preventDefault()
setAdditionalRoles((prev) => prev.filter((x) => x !== r)) setPendingAdditionalRole({
} role: r,
action: isAssigned ? 'remove' : 'add',
})
}} }}
/> >
{r.replace(/_/g, ' ')} {r.replace(/_/g, ' ')}
</label> </DropdownMenuCheckboxItem>
))} )
})}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -882,6 +942,59 @@ export default function MemberDetailPage() {
</Tabs> </Tabs>
{/* Super Admin Confirmation Dialog */} {/* Super Admin Confirmation Dialog */}
<AlertDialog
open={pendingAdditionalRole !== null}
onOpenChange={(open) => { if (!open) setPendingAdditionalRole(null) }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingAdditionalRole?.action === 'add' ? 'Add' : 'Remove'}{' '}
{pendingAdditionalRole?.role.replace(/_/g, ' ')} role?
</AlertDialogTitle>
<AlertDialogDescription>
{pendingAdditionalRole?.action === 'add' ? (
<>
This will give <strong>{name || user?.name || 'this user'}</strong>{' '}
the {pendingAdditionalRole.role.replace(/_/g, ' ')} dashboard
in addition to their primary role. They&apos;ll be able to
switch between dashboards from the role switcher. Click
&ldquo;Save changes&rdquo; below to apply.
</>
) : (
<>
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
They&apos;ll keep their primary role and any other additional
roles. Click &ldquo;Save changes&rdquo; below to apply.
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingAdditionalRole(null)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!pendingAdditionalRole) return
const { role: r, action } = pendingAdditionalRole
if (action === 'add') {
setAdditionalRoles((prev) =>
prev.includes(r) ? prev : [...prev, r]
)
} else {
setAdditionalRoles((prev) => prev.filter((x) => x !== r))
}
setPendingAdditionalRole(null)
}}
>
{pendingAdditionalRole?.action === 'add' ? 'Add role' : 'Remove role'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}> <AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>