refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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';
|
2026-06-06 23:53:53 -05:00
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
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>
|
2026-06-06 23:53:53 -05:00
|
|
|
{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>
|
|
|
|
|
)}
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-6 py-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-06-06 23:53:53 -05:00
|
|
|
<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>
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
<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">
|
2026-06-06 23:53:53 -05:00
|
|
|
{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>
|
|
|
|
|
)}
|
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
|
|
|
</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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|