UMBRA/frontend/src/components/admin/UserActionsMenu.tsx
Kyle Pope 48e15fa677 UX polish for delete-user: username toast, hide self-delete
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>
2026-02-27 19:30:43 +08:00

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>
);
}