324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api';
|
|
import { cn, errMessage } from '@/lib/utils';
|
|
import { Button, buttonVariants } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogContent,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogCancel,
|
|
AlertDialogAction,
|
|
} from '@/components/ui/alert-dialog';
|
|
import type { User } from '@/hooks/useAuth';
|
|
|
|
export interface AdminUser {
|
|
id: number;
|
|
username: string;
|
|
role?: string;
|
|
is_default_admin?: boolean;
|
|
active?: boolean | number;
|
|
must_change_password?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface ResetForm {
|
|
pw?: string;
|
|
open?: boolean;
|
|
}
|
|
|
|
interface UsersTableProps {
|
|
users?: AdminUser[];
|
|
onRefresh: () => void;
|
|
currentUser?: User | null;
|
|
}
|
|
|
|
export default function UsersTable({ users, onRefresh, currentUser }: UsersTableProps) {
|
|
const [resetForms, setResetForms] = useState<Record<number, ResetForm>>({});
|
|
const [deleting, setDeleting] = useState<number | null>(null);
|
|
const [resetting, setResetting] = useState<number | null>(null);
|
|
const [roleUpdating, setRoleUpdating] = useState<number | null>(null);
|
|
const [activeUpdating, setActiveUpdating] = useState<number | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
|
|
|
|
const setReset = (id: number, v: ResetForm) =>
|
|
setResetForms((p) => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
|
|
const getForm = (id: number): ResetForm => resetForms[id] || { pw: '', open: false };
|
|
|
|
const handleReset = async (user: AdminUser) => {
|
|
const form = getForm(user.id);
|
|
if (!form.pw || form.pw.length < 8) {
|
|
toast.error('Password must be at least 8 characters.');
|
|
return;
|
|
}
|
|
setResetting(user.id);
|
|
try {
|
|
await api.resetPassword(user.id, { password: form.pw });
|
|
toast.success(`Password reset for ${user.username}.`);
|
|
setReset(user.id, { pw: '', open: false });
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to reset password.'));
|
|
} finally {
|
|
setResetting(null);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
setDeleting(deleteTarget.id);
|
|
try {
|
|
await api.deleteUser(deleteTarget.id);
|
|
toast.success(`User ${deleteTarget.username} deleted.`);
|
|
setDeleteTarget(null);
|
|
onRefresh();
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to delete user.'));
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
const handleRoleChange = async (user: AdminUser, role: string) => {
|
|
if (user.role === role) return;
|
|
setRoleUpdating(user.id);
|
|
try {
|
|
await api.updateUserRole(user.id, { role });
|
|
toast.success(`${user.username} is now ${role === 'admin' ? 'an admin' : 'a user'}.`);
|
|
onRefresh();
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to update user role.'));
|
|
} finally {
|
|
setRoleUpdating(null);
|
|
}
|
|
};
|
|
|
|
const handleActiveChange = async (user: AdminUser, active: boolean) => {
|
|
setActiveUpdating(user.id);
|
|
try {
|
|
await api.updateUserActive(user.id, { active });
|
|
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
|
|
onRefresh();
|
|
} catch (err) {
|
|
toast.error(errMessage(err, 'Failed to update user status.'));
|
|
} finally {
|
|
setActiveUpdating(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle>Users</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">
|
|
Username
|
|
</th>
|
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Role</th>
|
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Status</th>
|
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">
|
|
Password
|
|
</th>
|
|
<th className="text-left px-6 py-3 text-muted-foreground font-medium">
|
|
Reset Password
|
|
</th>
|
|
<th className="px-6 py-3" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(users || []).map((user) => {
|
|
const form = getForm(user.id);
|
|
const isSelf = currentUser?.id === user.id;
|
|
return (
|
|
<tr
|
|
key={user.id}
|
|
className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
|
|
>
|
|
<td className="px-6 py-3 font-medium">
|
|
<div className="flex items-center gap-2">
|
|
<span>{user.username}</span>
|
|
{user.is_default_admin && (
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="secondary" className="cursor-default">
|
|
default admin
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
Initial admin account created during setup
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge
|
|
variant={user.role === 'admin' ? 'autodraft' : 'secondary'}
|
|
className="cursor-default"
|
|
>
|
|
{user.role}
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{user.role === 'admin'
|
|
? 'Full access including admin panel'
|
|
: 'Standard user — no admin access'}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<select
|
|
value={user.role}
|
|
disabled={isSelf || roleUpdating === user.id}
|
|
onChange={(e) => handleRoleChange(user, e.target.value)}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
|
title={isSelf ? 'You cannot change your own role' : 'Change user role'}
|
|
>
|
|
<option value="user">user</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<select
|
|
value={user.active === false || user.active === 0 ? 'inactive' : 'active'}
|
|
disabled={isSelf || activeUpdating === user.id}
|
|
onChange={(e) => handleActiveChange(user, e.target.value === 'active')}
|
|
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
|
|
title={
|
|
isSelf ? 'You cannot deactivate your own account' : 'Change user status'
|
|
}
|
|
>
|
|
<option value="active">active</option>
|
|
<option value="inactive">inactive</option>
|
|
</select>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
{user.must_change_password ? (
|
|
<TooltipProvider delayDuration={300}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="due_soon" className="cursor-default">
|
|
Temporary
|
|
</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
User must change their password on next login
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
) : (
|
|
<span className="text-muted-foreground">Set</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
{form.open ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="password"
|
|
placeholder="New password"
|
|
value={form.pw || ''}
|
|
onChange={(e) => setReset(user.id, { pw: e.target.value })}
|
|
className="h-8 text-sm w-36"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleReset(user)}
|
|
disabled={resetting === user.id}
|
|
>
|
|
{resetting === user.id ? '…' : 'Save'}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setReset(user.id, { open: false, pw: '' })}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setReset(user.id, { open: true })}
|
|
>
|
|
Reset
|
|
</Button>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-3 text-right">
|
|
{!isSelf && (
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => setDeleteTarget(user)}
|
|
disabled={deleting === user.id}
|
|
>
|
|
{deleting === user.id ? '…' : 'Delete'}
|
|
</Button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{!users?.length && (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">
|
|
No users found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<AlertDialog
|
|
open={!!deleteTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteTarget(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This is permanent in 2026. The user account and all user-owned data will be deleted,
|
|
including bills, payments, categories, monthly state, monthly starting amounts,
|
|
imports, import history, and sessions. This cannot be undone from Bill Tracker.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={!!deleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className={cn(buttonVariants({ variant: 'destructive' }))}
|
|
onClick={handleDelete}
|
|
disabled={!!deleting}
|
|
>
|
|
{deleting ? 'Deleting…' : 'Delete User'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
);
|
|
}
|