semblance/src/components/admin/UsersTab.tsx
Vadym Samoilenko 915c81b8f1 Complete phases D–G: quota enforcement, token invalidation, admin writes, backfill
Backend:
- token_version in JWT (bump_token_version, get_token_version on User model);
  jwt_required checks tv claim → 401 on mismatch; login routes embed version
- Quota pre-flight in all 3 LLM public methods (QuotaExceededError bubbles up)
- AI runner catches QuotaExceededError → sets status paused_quota + emits WS event
- Admin routes: POST /users (create), POST /users/<id>/reset-password,
  POST /pricing, GET /focus-groups with aggregated cost; PUT /users/<id>
  now bumps token_version on disable or role change
- backfill_usage.py: idempotent estimated-event generator for historical data,
  tiktoken for GPT models, char/3.8 for Gemini, --dry-run flag

Frontend:
- 402 interceptor dispatches quota_exceeded CustomEvent
- adminApi: createUser, resetPassword, createPricing, listFocusGroups
- UsersTab: New User dialog + Reset Password in edit dialog
- PricingTab: New Price dialog (model, provider, input/output/cached prices)
- FocusGroupsTab: focus groups table sorted by total cost
- Admin.tsx: 4th tab (Focus Groups)
- FocusGroupSession: admin-only cost badge + dismissable quota exceeded banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 18:34:48 +01:00

326 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useAdminUsers, useUpdateUser, useDisableUser, useEnableUser, useCreateUser, useResetPassword } from '@/hooks/useAdminUsers';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Loader2, Search, UserCog, Ban, CheckCircle, UserPlus } from 'lucide-react';
interface User {
_id: string;
username: string;
email: string;
role: string;
is_active?: boolean;
override_quota?: boolean;
quota?: { monthly_usd?: number };
cost_mtd?: number;
}
export default function UsersTab() {
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [editUser, setEditUser] = useState<User | null>(null);
const [editRole, setEditRole] = useState('user');
const [editQuota, setEditQuota] = useState('');
const [editOverride, setEditOverride] = useState(false);
const [resetPassword, setResetPassword] = useState('');
// Create user dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('user');
const { data, isLoading } = useAdminUsers({ q: search, role: roleFilter || undefined });
const updateUser = useUpdateUser();
const disableUser = useDisableUser();
const enableUser = useEnableUser();
const createUser = useCreateUser();
const resetPasswordMutation = useResetPassword();
const users: User[] = data?.users || [];
const openEdit = (u: User) => {
setEditUser(u);
setEditRole(u.role);
setEditQuota(u.quota?.monthly_usd?.toString() ?? '');
setEditOverride(u.override_quota ?? false);
setResetPassword('');
};
const handleCreate = () => {
createUser.mutate(
{ username: newUsername, email: newEmail, password: newPassword, role: newRole },
{
onSuccess: () => {
setShowCreateDialog(false);
setNewUsername('');
setNewEmail('');
setNewPassword('');
setNewRole('user');
},
}
);
};
const handleResetPassword = () => {
if (!editUser || !resetPassword) return;
resetPasswordMutation.mutate({ id: editUser._id, password: resetPassword }, {
onSuccess: () => setResetPassword(''),
});
};
const handleSave = () => {
if (!editUser) return;
const payload: any = { role: editRole, override_quota: editOverride };
if (editQuota) {
payload.quota = { monthly_usd: parseFloat(editQuota) };
} else {
payload.quota = {};
}
updateUser.mutate({ id: editUser._id, data: payload }, {
onSuccess: () => setEditUser(null),
});
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
className="pl-9"
placeholder="Search by name or email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<Select value={roleFilter || 'all'} onValueChange={v => setRoleFilter(v === 'all' ? '' : v)}>
<SelectTrigger className="w-36">
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All roles</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setShowCreateDialog(true)}>
<UserPlus className="mr-2 h-4 w-4" />
New User
</Button>
</div>
{/* Table */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>MTD Cost</TableHead>
<TableHead>Monthly Quota</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-slate-500 py-8">
No users found
</TableCell>
</TableRow>
)}
{users.map(u => (
<TableRow key={u._id}>
<TableCell>
<div className="font-medium">{u.username}</div>
<div className="text-xs text-slate-500">{u.email}</div>
</TableCell>
<TableCell>
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
{u.role}
</Badge>
{u.override_quota && (
<Badge variant="outline" className="ml-1 text-xs">no quota</Badge>
)}
</TableCell>
<TableCell>
<Badge variant={u.is_active === false ? 'destructive' : 'outline'}>
{u.is_active === false ? 'Disabled' : 'Active'}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
${(u.cost_mtd ?? 0).toFixed(4)}
</TableCell>
<TableCell className="text-sm text-slate-600">
{u.quota?.monthly_usd ? `$${u.quota.monthly_usd}/mo` : '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex gap-1 justify-end">
<Button size="sm" variant="ghost" onClick={() => openEdit(u)} title="Edit">
<UserCog className="h-4 w-4" />
</Button>
{u.is_active === false ? (
<Button size="sm" variant="ghost" onClick={() => enableUser.mutate(u._id)} title="Enable">
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
) : (
<Button size="sm" variant="ghost" onClick={() => disableUser.mutate(u._id)} title="Disable">
<Ban className="h-4 w-4 text-red-500" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Edit Dialog */}
<Dialog open={!!editUser} onOpenChange={open => !open && setEditUser(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Role</Label>
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Monthly quota (USD, blank = unlimited)</Label>
<Input
type="number"
min="0"
step="1"
placeholder="e.g. 50"
value={editQuota}
onChange={e => setEditQuota(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<input
id="override"
type="checkbox"
checked={editOverride}
onChange={e => setEditOverride(e.target.checked)}
className="h-4 w-4 rounded border-slate-300"
/>
<Label htmlFor="override" className="cursor-pointer">
Override quota (bypass spending limit)
</Label>
</div>
<div className="border-t pt-4 space-y-1">
<Label>Reset Password</Label>
<div className="flex gap-2">
<Input
type="password"
placeholder="New password..."
value={resetPassword}
onChange={e => setResetPassword(e.target.value)}
/>
<Button
variant="outline"
onClick={handleResetPassword}
disabled={!resetPassword || resetPasswordMutation.isPending}
>
{resetPasswordMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Reset
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={updateUser.isPending}>
{updateUser.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Create User Dialog */}
<Dialog open={showCreateDialog} onOpenChange={open => !open && setShowCreateDialog(false)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Username</Label>
<Input
placeholder="username"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Email</Label>
<Input
type="email"
placeholder="user@example.com"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Password</Label>
<Input
type="password"
placeholder="Password..."
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>Role</Label>
<Select value={newRole} onValueChange={setNewRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!newUsername || !newEmail || !newPassword || createUser.isPending}
>
{createUser.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}