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
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:
@@ -56,6 +56,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -80,6 +88,8 @@ import {
|
||||
Link as LinkIcon,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
@@ -185,6 +195,10 @@ export default function MemberDetailPage() {
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
const [showSuperAdminConfirm, setShowSuperAdminConfirm] = 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[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -691,26 +705,72 @@ export default function MemberDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<Label>Additional Roles</Label>
|
||||
<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'll be
|
||||
asked to confirm each change.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['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))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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, ' ')}
|
||||
</label>
|
||||
))}
|
||||
<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)
|
||||
.filter((r) => r !== role)
|
||||
.map((r) => {
|
||||
const isAssigned = additionalRoles.includes(r)
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={r}
|
||||
checked={isAssigned}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setPendingAdditionalRole({
|
||||
role: r,
|
||||
action: isAssigned ? 'remove' : 'add',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{r.replace(/_/g, ' ')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -882,6 +942,59 @@ export default function MemberDetailPage() {
|
||||
</Tabs>
|
||||
|
||||
{/* 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'll be able to
|
||||
switch between dashboards from the role switcher. Click
|
||||
“Save changes” below to apply.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will revoke the {pendingAdditionalRole?.role.replace(/_/g, ' ')}
|
||||
{' '}dashboard from <strong>{name || user?.name || 'this user'}</strong>.
|
||||
They'll keep their primary role and any other additional
|
||||
roles. Click “Save changes” 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}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user