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,
|
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'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'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}>
|
<AlertDialog open={showSuperAdminConfirm} onOpenChange={setShowSuperAdminConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user