Files
office_translator/office-translator-landing-page/components/admin-user-table.tsx
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
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>
2026-04-25 15:01:47 +02:00

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>
)
}