Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useCallback } from "react"
|
|
import {
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
} from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Table,
|
|
TableHeader,
|
|
TableBody,
|
|
TableHead,
|
|
TableRow,
|
|
TableCell,
|
|
} from "@/components/ui/table"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
} from "@/components/ui/tooltip"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import {
|
|
KeyRound,
|
|
Users,
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from "lucide-react"
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
interface UserRow {
|
|
id: string
|
|
email: string
|
|
status: "active" | "suspended" | "pending"
|
|
tier: "free" | "pro"
|
|
usage: number
|
|
maxUsage: number
|
|
apiKeys: number
|
|
lastActive: string
|
|
}
|
|
|
|
const initialUsers: UserRow[] = [
|
|
{
|
|
id: "1",
|
|
email: "sarah.chen@acme.com",
|
|
status: "active",
|
|
tier: "pro",
|
|
usage: 423,
|
|
maxUsage: 500,
|
|
apiKeys: 3,
|
|
lastActive: "2 min ago",
|
|
},
|
|
{
|
|
id: "2",
|
|
email: "m.rodriguez@startup.io",
|
|
status: "active",
|
|
tier: "pro",
|
|
usage: 189,
|
|
maxUsage: 500,
|
|
apiKeys: 1,
|
|
lastActive: "1 hour ago",
|
|
},
|
|
{
|
|
id: "3",
|
|
email: "j.mueller@berlin-corp.de",
|
|
status: "active",
|
|
tier: "free",
|
|
usage: 18,
|
|
maxUsage: 50,
|
|
apiKeys: 1,
|
|
lastActive: "3 hours ago",
|
|
},
|
|
{
|
|
id: "4",
|
|
email: "alex.kim@design.co",
|
|
status: "pending",
|
|
tier: "free",
|
|
usage: 0,
|
|
maxUsage: 50,
|
|
apiKeys: 0,
|
|
lastActive: "Never",
|
|
},
|
|
{
|
|
id: "5",
|
|
email: "nina.patel@healthco.org",
|
|
status: "active",
|
|
tier: "pro",
|
|
usage: 501,
|
|
maxUsage: 500,
|
|
apiKeys: 2,
|
|
lastActive: "18 min ago",
|
|
},
|
|
{
|
|
id: "6",
|
|
email: "tom.wright@legal.uk",
|
|
status: "suspended",
|
|
tier: "free",
|
|
usage: 47,
|
|
maxUsage: 50,
|
|
apiKeys: 0,
|
|
lastActive: "14 days ago",
|
|
},
|
|
{
|
|
id: "7",
|
|
email: "l.tanaka@jp-trade.co.jp",
|
|
status: "active",
|
|
tier: "pro",
|
|
usage: 312,
|
|
maxUsage: 500,
|
|
apiKeys: 2,
|
|
lastActive: "45 min ago",
|
|
},
|
|
{
|
|
id: "8",
|
|
email: "c.dubois@paris-finance.fr",
|
|
status: "active",
|
|
tier: "free",
|
|
usage: 34,
|
|
maxUsage: 50,
|
|
apiKeys: 1,
|
|
lastActive: "6 hours ago",
|
|
},
|
|
]
|
|
|
|
const statusConfig = {
|
|
active: {
|
|
label: "Active",
|
|
dotClass: "bg-[oklch(0.59_0.16_145)]",
|
|
textClass: "text-[oklch(0.45_0.12_145)]",
|
|
},
|
|
suspended: {
|
|
label: "Suspended",
|
|
dotClass: "bg-destructive",
|
|
textClass: "text-destructive",
|
|
},
|
|
pending: {
|
|
label: "Pending",
|
|
dotClass: "bg-[oklch(0.75_0.18_55)]",
|
|
textClass: "text-[oklch(0.55_0.16_55)]",
|
|
},
|
|
}
|
|
|
|
export function UserManagementTable() {
|
|
const [users, setUsers] = useState<UserRow[]>(initialUsers)
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [revokedUsers, setRevokedUsers] = useState<Set<string>>(new Set())
|
|
|
|
const updateTier = useCallback((userId: string, newTier: "free" | "pro") => {
|
|
setUsers((prev) =>
|
|
prev.map((u) =>
|
|
u.id === userId
|
|
? {
|
|
...u,
|
|
tier: newTier,
|
|
maxUsage: newTier === "pro" ? 500 : 50,
|
|
}
|
|
: u
|
|
)
|
|
)
|
|
}, [])
|
|
|
|
const revokeKeys = useCallback((userId: string) => {
|
|
setUsers((prev) =>
|
|
prev.map((u) => (u.id === userId ? { ...u, apiKeys: 0 } : u))
|
|
)
|
|
setRevokedUsers((prev) => {
|
|
const next = new Set(prev)
|
|
next.add(userId)
|
|
return next
|
|
})
|
|
setTimeout(() => {
|
|
setRevokedUsers((prev) => {
|
|
const next = new Set(prev)
|
|
next.delete(userId)
|
|
return next
|
|
})
|
|
}, 2000)
|
|
}, [])
|
|
|
|
const filtered = users.filter((u) =>
|
|
u.email.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
|
|
const proCount = users.filter((u) => u.tier === "pro").length
|
|
const activeCount = users.filter((u) => u.status === "active").length
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex size-8 items-center justify-center rounded-lg bg-secondary">
|
|
<Users className="size-4 text-foreground" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-sm">User Management</CardTitle>
|
|
<CardDescription className="text-xs">
|
|
{users.length} total users
|
|
<span className="mx-1.5 text-border">|</span>
|
|
{activeCount} active
|
|
<span className="mx-1.5 text-border">|</span>
|
|
{proCount} pro
|
|
</CardDescription>
|
|
</div>
|
|
</div>
|
|
<div className="relative w-56">
|
|
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Filter by email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-8 pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="px-0 pb-0">
|
|
<div className="border-t border-border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="hover:bg-transparent">
|
|
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
User Email
|
|
</TableHead>
|
|
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
Status
|
|
</TableHead>
|
|
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
Tier
|
|
</TableHead>
|
|
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
Usage
|
|
</TableHead>
|
|
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
Keys
|
|
</TableHead>
|
|
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
Actions
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((user) => {
|
|
const sConfig = statusConfig[user.status]
|
|
const usagePercent = Math.min(
|
|
(user.usage / user.maxUsage) * 100,
|
|
100
|
|
)
|
|
const isOverQuota = user.usage > user.maxUsage
|
|
const justRevoked = revokedUsers.has(user.id)
|
|
|
|
return (
|
|
<TableRow key={user.id} className="group">
|
|
<TableCell className="pl-6 py-2">
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-medium text-foreground">
|
|
{user.email}
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
Last active: {user.lastActive}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell className="py-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={`size-1.5 rounded-full ${sConfig.dotClass}`}
|
|
/>
|
|
<span className={`text-xs font-medium ${sConfig.textClass}`}>
|
|
{sConfig.label}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell className="py-2">
|
|
<Select
|
|
value={user.tier}
|
|
onValueChange={(val: "free" | "pro") =>
|
|
updateTier(user.id, val)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
size="sm"
|
|
className={`h-6 w-[72px] text-[10px] font-semibold uppercase tracking-wider ${
|
|
user.tier === "pro"
|
|
? "border-accent/30 bg-accent/10 text-accent"
|
|
: "border-border text-muted-foreground"
|
|
}`}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="free" className="text-xs">
|
|
Free
|
|
</SelectItem>
|
|
<SelectItem value="pro" className="text-xs">
|
|
Pro
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
|
|
<TableCell className="py-2">
|
|
<div className="flex w-32 flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`text-[10px] font-medium tabular-nums ${
|
|
isOverQuota
|
|
? "text-destructive"
|
|
: "text-muted-foreground"
|
|
}`}
|
|
>
|
|
{user.usage} / {user.maxUsage}
|
|
</span>
|
|
{isOverQuota && (
|
|
<Badge
|
|
variant="outline"
|
|
className="h-4 border-destructive/30 bg-destructive/5 px-1 text-[9px] text-destructive"
|
|
>
|
|
Over
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Progress
|
|
value={usagePercent}
|
|
className={`h-1 bg-muted ${
|
|
isOverQuota
|
|
? "[&>[data-slot=progress-indicator]]:bg-destructive"
|
|
: usagePercent > 80
|
|
? "[&>[data-slot=progress-indicator]]:bg-[oklch(0.75_0.18_55)]"
|
|
: "[&>[data-slot=progress-indicator]]:bg-accent"
|
|
}`}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell className="py-2">
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{user.apiKeys}
|
|
</span>
|
|
</TableCell>
|
|
|
|
<TableCell className="pr-6 py-2 text-right">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={`h-6 gap-1 px-2 text-[10px] ${
|
|
justRevoked
|
|
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
|
|
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
}`}
|
|
onClick={() => revokeKeys(user.id)}
|
|
disabled={user.apiKeys === 0 || justRevoked}
|
|
>
|
|
<KeyRound className="size-2.5" />
|
|
{justRevoked ? "Revoked" : "Revoke Keys"}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="text-xs">
|
|
{user.apiKeys === 0
|
|
? "No active keys"
|
|
: `Revoke ${user.apiKeys} active key${user.apiKeys > 1 ? "s" : ""}`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
|
|
{filtered.length === 0 && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={6}
|
|
className="py-8 text-center text-xs text-muted-foreground"
|
|
>
|
|
No users match your filter.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{/* Pagination footer */}
|
|
<div className="flex items-center justify-between border-t border-border px-6 py-2">
|
|
<span className="text-[10px] text-muted-foreground">
|
|
Showing {filtered.length} of {users.length} users
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="size-6"
|
|
disabled
|
|
aria-label="Previous page"
|
|
>
|
|
<ChevronLeft className="size-3" />
|
|
</Button>
|
|
<span className="px-2 text-[10px] font-medium text-foreground">1 / 1</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="size-6"
|
|
disabled
|
|
aria-label="Next page"
|
|
>
|
|
<ChevronRight className="size-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|