BillTracker/client/components/admin/UsersTable.jsx

238 lines
11 KiB
React
Raw Normal View History

import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn } 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';
export default function UsersTable({ users, onRefresh, currentUser }) {
const [resetForms, setResetForms] = useState({});
const [deleting, setDeleting] = useState(null);
const [resetting, setResetting] = useState(null);
const [roleUpdating, setRoleUpdating] = useState(null);
const [activeUpdating, setActiveUpdating] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const setReset = (id, v) => setResetForms(p => ({ ...p, [id]: { ...(p[id] || {}), ...v } }));
const getForm = (id) => resetForms[id] || { pw: '', open: false };
const handleReset = async (user) => {
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(err.message || '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(err.message || 'Failed to delete user.');
} finally {
setDeleting(null);
}
};
const handleRoleChange = async (user, role) => {
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(err.message || 'Failed to update user role.');
} finally {
setRoleUpdating(null);
}
};
const handleActiveChange = async (user, active) => {
setActiveUpdating(user.id);
try {
await api.updateUserActive(user.id, { active });
toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`);
onRefresh();
} catch (err) {
toast.error(err.message || '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 BillTracker.
</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>
</>
);
}