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>
This commit is contained in:
430
office-translator-landing-page/components/admin-user-table.tsx
Normal file
430
office-translator-landing-page/components/admin-user-table.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user