S-03: Delete toast now shows the deleted username from the API response S-04: Delete button hidden for the current admin's own row (backend still guards with 403, but no reason to show a dead button) Adds username to auth status response so the frontend can identify the current user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
MoreHorizontal,
|
|
ShieldCheck,
|
|
KeyRound,
|
|
UserX,
|
|
UserCheck,
|
|
LogOut,
|
|
Smartphone,
|
|
ChevronRight,
|
|
Loader2,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
|
import {
|
|
useUpdateRole,
|
|
useResetPassword,
|
|
useDisableMfa,
|
|
useEnforceMfa,
|
|
useRemoveMfaEnforcement,
|
|
useToggleUserActive,
|
|
useRevokeSessions,
|
|
useDeleteUser,
|
|
getErrorMessage,
|
|
} from '@/hooks/useAdmin';
|
|
import type { AdminUserDetail, UserRole } from '@/types';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface UserActionsMenuProps {
|
|
user: AdminUserDetail;
|
|
currentUsername: string | null;
|
|
}
|
|
|
|
const ROLES: { value: UserRole; label: string }[] = [
|
|
{ value: 'admin', label: 'Admin' },
|
|
{ value: 'standard', label: 'Standard' },
|
|
{ value: 'public_event_manager', label: 'Public Event Manager' },
|
|
];
|
|
|
|
export default function UserActionsMenu({ user, currentUsername }: UserActionsMenuProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false);
|
|
const [tempPassword, setTempPassword] = useState<string | null>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const updateRole = useUpdateRole();
|
|
const resetPassword = useResetPassword();
|
|
const disableMfa = useDisableMfa();
|
|
const enforceMfa = useEnforceMfa();
|
|
const removeMfaEnforcement = useRemoveMfaEnforcement();
|
|
const toggleActive = useToggleUserActive();
|
|
const revokeSessions = useRevokeSessions();
|
|
const deleteUser = useDeleteUser();
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const handleOutside = (e: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
setRoleSubmenuOpen(false);
|
|
}
|
|
};
|
|
if (open) document.addEventListener('mousedown', handleOutside);
|
|
return () => document.removeEventListener('mousedown', handleOutside);
|
|
}, [open]);
|
|
|
|
const handleAction = async (fn: () => Promise<unknown>, successMsg: string) => {
|
|
try {
|
|
await fn();
|
|
toast.success(successMsg);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
toast.error(getErrorMessage(err, 'Action failed'));
|
|
}
|
|
};
|
|
|
|
// Two-click confirms
|
|
const disableMfaConfirm = useConfirmAction(() => {
|
|
handleAction(() => disableMfa.mutateAsync(user.id), 'MFA disabled');
|
|
});
|
|
|
|
const toggleActiveConfirm = useConfirmAction(() => {
|
|
handleAction(
|
|
() => toggleActive.mutateAsync({ userId: user.id, active: !user.is_active }),
|
|
user.is_active ? 'Account disabled' : 'Account enabled'
|
|
);
|
|
});
|
|
|
|
const revokeSessionsConfirm = useConfirmAction(() => {
|
|
handleAction(() => revokeSessions.mutateAsync(user.id), 'Sessions revoked');
|
|
});
|
|
|
|
const deleteUserConfirm = useConfirmAction(async () => {
|
|
try {
|
|
const result = await deleteUser.mutateAsync(user.id);
|
|
toast.success(`User '${(result as { deleted_username: string }).deleted_username}' permanently deleted`);
|
|
setOpen(false);
|
|
} catch (err) {
|
|
toast.error(getErrorMessage(err, 'Delete failed'));
|
|
}
|
|
});
|
|
|
|
const isLoading =
|
|
updateRole.isPending ||
|
|
resetPassword.isPending ||
|
|
disableMfa.isPending ||
|
|
enforceMfa.isPending ||
|
|
removeMfaEnforcement.isPending ||
|
|
toggleActive.isPending ||
|
|
revokeSessions.isPending ||
|
|
deleteUser.isPending;
|
|
|
|
return (
|
|
<div ref={menuRef} className="relative">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label="User actions"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 top-8 z-50 min-w-[200px] rounded-lg border bg-card shadow-lg py-1">
|
|
{/* Edit Role */}
|
|
<div className="relative">
|
|
<button
|
|
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
|
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
|
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
|
onClick={() => setRoleSubmenuOpen((v) => !v)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
|
Edit Role
|
|
</span>
|
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</button>
|
|
|
|
{roleSubmenuOpen && (
|
|
<div
|
|
className="absolute right-full top-0 z-50 min-w-[180px] rounded-lg border bg-card shadow-lg py-1"
|
|
onMouseEnter={() => setRoleSubmenuOpen(true)}
|
|
onMouseLeave={() => setRoleSubmenuOpen(false)}
|
|
>
|
|
{ROLES.map(({ value, label }) => (
|
|
<button
|
|
key={value}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors',
|
|
user.role === value && 'text-accent'
|
|
)}
|
|
onClick={() =>
|
|
handleAction(
|
|
() => updateRole.mutateAsync({ userId: user.id, role: value }),
|
|
`Role updated to ${label}`
|
|
)
|
|
}
|
|
>
|
|
{user.role === value && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reset Password */}
|
|
{tempPassword ? (
|
|
<div className="px-3 py-2 space-y-1.5">
|
|
<p className="text-[11px] text-muted-foreground">Temporary password:</p>
|
|
<code className="block px-2 py-1.5 bg-card-elevated rounded text-xs font-mono text-accent select-all break-all">
|
|
{tempPassword}
|
|
</code>
|
|
<button
|
|
className="w-full rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => {
|
|
setTempPassword(null);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
|
onClick={async () => {
|
|
try {
|
|
const result = await resetPassword.mutateAsync(user.id);
|
|
setTempPassword((result as { temporary_password: string }).temporary_password);
|
|
toast.success('Password reset — user must change on next login');
|
|
} catch (err) {
|
|
toast.error(getErrorMessage(err, 'Password reset failed'));
|
|
}
|
|
}}
|
|
>
|
|
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
|
Reset Password
|
|
</button>
|
|
)}
|
|
|
|
<div className="my-1 border-t border-border" />
|
|
|
|
{/* MFA actions */}
|
|
{user.mfa_enforce_pending ? (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
|
onClick={() =>
|
|
handleAction(
|
|
() => removeMfaEnforcement.mutateAsync(user.id),
|
|
'MFA enforcement removed'
|
|
)
|
|
}
|
|
>
|
|
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
|
Remove MFA Enforcement
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
|
onClick={() =>
|
|
handleAction(() => enforceMfa.mutateAsync(user.id), 'MFA enforcement set')
|
|
}
|
|
>
|
|
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
|
Enforce MFA
|
|
</button>
|
|
)}
|
|
|
|
{user.totp_enabled && (
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
|
disableMfaConfirm.confirming
|
|
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
|
: 'hover:bg-card-elevated'
|
|
)}
|
|
onClick={disableMfaConfirm.handleClick}
|
|
>
|
|
<Smartphone className="h-4 w-4" />
|
|
{disableMfaConfirm.confirming ? 'Sure? Click to confirm' : 'Disable MFA'}
|
|
</button>
|
|
)}
|
|
|
|
<div className="my-1 border-t border-border" />
|
|
|
|
{/* Disable / Enable Account */}
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
|
toggleActiveConfirm.confirming
|
|
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
|
: 'hover:bg-card-elevated'
|
|
)}
|
|
onClick={toggleActiveConfirm.handleClick}
|
|
>
|
|
{user.is_active ? (
|
|
<>
|
|
<UserX className="h-4 w-4" />
|
|
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Disable Account'}
|
|
</>
|
|
) : (
|
|
<>
|
|
<UserCheck className="h-4 w-4 text-green-400" />
|
|
{toggleActiveConfirm.confirming ? 'Sure? Click to confirm' : 'Enable Account'}
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{/* Revoke Sessions */}
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
|
revokeSessionsConfirm.confirming
|
|
? 'text-orange-400 bg-orange-500/10 hover:bg-orange-500/15'
|
|
: 'hover:bg-card-elevated'
|
|
)}
|
|
onClick={revokeSessionsConfirm.handleClick}
|
|
>
|
|
<LogOut className="h-4 w-4" />
|
|
{revokeSessionsConfirm.confirming ? 'Sure? Click to confirm' : 'Revoke All Sessions'}
|
|
</button>
|
|
|
|
{/* Delete User — hidden for own account */}
|
|
{currentUsername !== user.username && (
|
|
<>
|
|
<div className="my-1 border-t border-border" />
|
|
|
|
<button
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
|
deleteUserConfirm.confirming
|
|
? 'text-red-400 bg-red-500/10 hover:bg-red-500/15'
|
|
: 'text-red-400 hover:bg-card-elevated'
|
|
)}
|
|
onClick={deleteUserConfirm.handleClick}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
{deleteUserConfirm.confirming ? 'Sure? This is permanent' : 'Delete User'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|