Files
tool-evaluator/artifacts/toolrate/src/pages/admin.tsx
T
opencode 743b177c89
Build & Push Docker Image / build (push) Successful in 2m21s
fix(auth): password min length 6 + show/hide toggle on password inputs
2026-08-03 08:01:52 +02:00

506 lines
22 KiB
TypeScript

import { useState } from "react";
import { useLocation, Link } from "wouter";
import {
useListUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
useSetUserPassword,
useListAuditLogs,
useGetVersion,
getListUsersQueryKey,
getListAuditLogsQueryKey,
getGetVersionQueryKey,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Users, ScrollText, ShieldAlert, Plus, Pencil, Trash2, Clock, AlertTriangle, Wrench, Server } from "lucide-react";
import { format } from "date-fns";
import { AdminToolsTab } from "@/components/admin-tools-tab";
export default function Admin() {
const [, setLocation] = useLocation();
const { user, isAdmin, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
const [editPassword, setEditPassword] = useState("");
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [newEmail, setNewEmail] = useState("");
const [newRole, setNewRole] = useState<"admin" | "user">("user");
const [newTier, setNewTier] = useState<"free" | "premium" | "enterprise">("free");
const { data: users, isLoading: loadingUsers } = useListUsers({
query: { queryKey: getListUsersQueryKey(), enabled: isAdmin },
});
const { data: auditLogs, isLoading: loadingLogs } = useListAuditLogs(
{ limit: 100 },
{ query: { queryKey: getListAuditLogsQueryKey({ limit: 100 }), enabled: isAdmin } },
);
const { data: versionInfo } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false, enabled: isAdmin },
});
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const setUserPassword = useSetUserPassword();
if (!authLoading && !isAdmin) {
return (
<Layout>
<div className="flex flex-col items-center justify-center py-20 gap-4">
<ShieldAlert className="w-12 h-12 text-muted-foreground" />
<h2 className="text-2xl font-bold">Admin Access Required</h2>
<p className="text-muted-foreground">You need admin rights to view this page.</p>
<Button variant="outline" onClick={() => setLocation("/")}>Go Home</Button>
</div>
</Layout>
);
}
const handleCreateUser = () => {
if (!newUsername || !newPassword) return;
createUser.mutate(
{ data: { username: newUsername, password: newPassword, email: newEmail || undefined, role: newRole, tier: newTier } },
{
onSuccess: () => {
toast({ title: "User created", description: `${newUsername} has been created.` });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setCreateOpen(false);
setNewUsername("");
setNewPassword("");
setNewEmail("");
setNewRole("user");
},
onError: (err) => {
toast({ title: "Failed to create user", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
};
const handleUpdateUser = () => {
if (!editUser) return;
updateUser.mutate(
{ id: editUser.id, data: { role: editUser.role as "admin" | "user", tier: editUser.tier as "free" | "premium" | "enterprise" } },
{
onSuccess: () => {
toast({ title: "User updated" });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setEditUser(null);
},
onError: (err) => {
toast({ title: "Failed to update user", description: err.data?.error ?? err.message, variant: "destructive" });
},
},
);
};
const handleSetUserPassword = () => {
if (!editUser || !editPassword) return;
if (editPassword.length < 6) {
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
return;
}
setUserPassword.mutate(
{ id: editUser.id, data: { password: editPassword } },
{
onSuccess: () => {
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
setEditPassword("");
},
onError: (err) => {
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
};
const handleDeleteUser = () => {
if (!deleteConfirm) return;
deleteUser.mutate(
{ id: deleteConfirm.id },
{
onSuccess: () => {
toast({ title: "User deleted", description: `${deleteConfirm.username} has been removed.` });
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
setDeleteConfirm(null);
},
onError: (err) => {
toast({ title: "Failed to delete user", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
};
function actionBadgeVariant(action: string): "default" | "secondary" | "destructive" | "outline" {
if (action === "create") return "default";
if (action === "delete") return "destructive";
return "secondary";
}
return (
<Layout>
<div className="space-y-6 pb-10">
<div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Admin Panel</h1>
<p className="text-muted-foreground">Manage users and review system changes.</p>
</div>
<Button asChild variant="outline" size="sm" className="gap-2">
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> Redundancy Dashboard</Link>
</Button>
</div>
</div>
<Tabs defaultValue="users">
<TabsList className="mb-4">
<TabsTrigger value="users" className="gap-2">
<Users className="w-4 h-4" /> Users
</TabsTrigger>
<TabsTrigger value="tools" className="gap-2">
<Wrench className="w-4 h-4" /> Tools
</TabsTrigger>
<TabsTrigger value="audit" className="gap-2">
<ScrollText className="w-4 h-4" /> Audit Log
</TabsTrigger>
<TabsTrigger value="system" className="gap-2">
<Server className="w-4 h-4" /> System
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Local Users</CardTitle>
<CardDescription>Manage accounts for local authentication.</CardDescription>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" /> Add User
</Button>
</CardHeader>
<CardContent>
{loadingUsers ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="divide-y">
{users?.map((u) => (
<div key={u.id} className="flex items-center justify-between py-3 gap-4">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 font-medium text-primary text-sm">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="font-medium text-sm truncate">{u.username}</p>
{u.email && <p className="text-xs text-muted-foreground truncate">{u.email}</p>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={u.role === "admin" ? "default" : "secondary"}>
{u.role}
</Badge>
<Badge variant="outline" className="capitalize">
{u.tier ?? "free"}
</Badge>
{u.username !== user?.preferredUsername && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteConfirm({ id: u.id, username: u.username })}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</>
)}
</div>
</div>
))}
{(!users || users.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No users yet.</p>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tools">
<AdminToolsTab />
</TabsContent>
<TabsContent value="audit">
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>All create, update and delete operations tracked by the system.</CardDescription>
</CardHeader>
<CardContent>
{loadingLogs ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : (
<div className="divide-y">
{auditLogs?.map((log) => (
<div key={log.id} className="py-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={actionBadgeVariant(log.action)} className="capitalize text-xs">
{log.action}
</Badge>
<span className="text-sm font-medium capitalize">{log.entityType}</span>
{log.entityId && (
<span className="text-sm text-muted-foreground">#{log.entityId}</span>
)}
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-1">
<Clock className="w-3 h-3" />
{format(new Date(log.createdAt), "dd.MM.yyyy HH:mm")}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>by <span className="font-medium text-foreground">{log.username}</span></span>
{log.changes && (
<span className="truncate max-w-[400px] font-mono bg-muted px-1.5 py-0.5 rounded text-[11px]">
{log.changes.length > 120 ? log.changes.slice(0, 120) + "…" : log.changes}
</span>
)}
</div>
</div>
))}
{(!auditLogs || auditLogs.length === 0) && (
<p className="text-sm text-muted-foreground py-4 text-center">No audit entries yet.</p>
)}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>System</CardTitle>
<CardDescription>Build information of the currently live deployment.</CardDescription>
</CardHeader>
<CardContent>
<div className="divide-y">
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Version</span>
<span className="text-sm font-medium">{versionInfo?.version || "dev"}</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Commit</span>
{versionInfo?.commitSha ? (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/commit/${versionInfo.commitSha}`}
target="_blank"
rel="noreferrer"
className="text-sm font-mono text-primary hover:underline"
title={versionInfo.commitSha}
>
{versionInfo.commitSha.slice(0, 7)}
</a>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Build date</span>
<span className="text-sm">
{versionInfo?.buildDate ? format(new Date(versionInfo.buildDate), "dd.MM.yyyy HH:mm") : "—"}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-muted-foreground">Trash retention</span>
<span className="text-sm">
{(versionInfo?.trashRetentionDays ?? 0) > 0
? `${versionInfo?.trashRetentionDays} days`
: "Keep forever"}
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Username</Label>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder="username" />
</div>
<div className="space-y-2">
<Label>Password</Label>
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
</div>
<div className="space-y-2">
<Label>Email (Optional)</Label>
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder="user@example.com" />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
{createUser.isPending ? "Creating…" : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Role</Label>
<Select
value={editUser?.role || "user"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Plan</Label>
<Select
value={editUser?.tier || "free"}
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<PasswordInput
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 6 characters"
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
</div>
) : (
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
Password is managed by the identity provider (Keycloak). Reset it there.
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
variant="outline"
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting…" : "Set Password"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground py-2">
Are you sure you want to delete <span className="font-medium text-foreground">{deleteConfirm?.username}</span>? This cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
{deleteUser.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}