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>
326 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|