3de59ccaaf
Build & Push Docker Image / build (push) Successful in 2m33s
- translate remaining pages/components (admin, analytics, redundancy, trash, compare, browse, watchlist, admin tools tab, category combobox, theme toggle, tool preview card, tool-new/edit) to t() calls - locales: 453 keys each, parity verified - docs: bilingual handbook + release notes via *.en.md variants, language-aware docs.tsx (markdown paths, nav titles, search index), LanguageSwitcher in docs header - generate-docs: emit per-locale handbook/release/search output, localized index.json fields (fileEn/titleEn), en-aware snapshots
507 lines
23 KiB
TypeScript
507 lines
23 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";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export default function Admin() {
|
|
const { t } = useTranslation();
|
|
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">{t("admin.accessRequired")}</h2>
|
|
<p className="text-muted-foreground">{t("admin.accessRequiredSub")}</p>
|
|
<Button variant="outline" onClick={() => setLocation("/")}>{t("admin.goHome")}</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: t("admin.toastUserCreated"), description: t("admin.toastUserCreatedSub", { username: newUsername }) });
|
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
|
setCreateOpen(false);
|
|
setNewUsername("");
|
|
setNewPassword("");
|
|
setNewEmail("");
|
|
setNewRole("user");
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("admin.toastUserCreateFailed"), 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: t("admin.toastUserUpdated") });
|
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
|
setEditUser(null);
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("admin.toastUserUpdateFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleSetUserPassword = () => {
|
|
if (!editUser || !editPassword) return;
|
|
if (editPassword.length < 6) {
|
|
toast({ title: t("admin.toastPwTooShort"), description: t("admin.toastPwTooShortSub"), variant: "destructive" });
|
|
return;
|
|
}
|
|
setUserPassword.mutate(
|
|
{ id: editUser.id, data: { password: editPassword } },
|
|
{
|
|
onSuccess: () => {
|
|
toast({ title: t("admin.toastPwUpdated"), description: t("admin.toastPwUpdatedSub", { username: editUser.username }) });
|
|
setEditPassword("");
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("admin.toastPwSetFailed"), 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: t("admin.toastUserDeleted"), description: t("admin.toastUserDeletedSub", { username: deleteConfirm.username }) });
|
|
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
|
setDeleteConfirm(null);
|
|
},
|
|
onError: (err) => {
|
|
toast({ title: t("admin.toastUserDeleteFailed"), 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">{t("admin.panel")}</h1>
|
|
<p className="text-muted-foreground">{t("admin.panelSub")}</p>
|
|
</div>
|
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
<Link href="/admin/redundancy"><AlertTriangle className="w-4 h-4" /> {t("admin.redundancyDashboard")}</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<Tabs defaultValue="users">
|
|
<TabsList className="mb-4">
|
|
<TabsTrigger value="users" className="gap-2">
|
|
<Users className="w-4 h-4" /> {t("admin.tabUsers")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="tools" className="gap-2">
|
|
<Wrench className="w-4 h-4" /> {t("admin.tabTools")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="audit" className="gap-2">
|
|
<ScrollText className="w-4 h-4" /> {t("admin.tabAudit")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="system" className="gap-2">
|
|
<Server className="w-4 h-4" /> {t("admin.tabSystem")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="users">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>{t("admin.localUsers")}</CardTitle>
|
|
<CardDescription>{t("admin.localUsersSub")}</CardDescription>
|
|
</div>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="w-4 h-4 mr-2" /> {t("admin.addUser")}
|
|
</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">{t("admin.noUsersYet")}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tools">
|
|
<AdminToolsTab />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="audit">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("admin.auditLog")}</CardTitle>
|
|
<CardDescription>{t("admin.auditLogSub")}</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>{t("admin.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">{t("admin.noAuditEntries")}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="system">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t("admin.system")}</CardTitle>
|
|
<CardDescription>{t("admin.systemSub")}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="divide-y">
|
|
<div className="flex items-center justify-between py-3">
|
|
<span className="text-sm text-muted-foreground">{t("admin.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">{t("admin.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">{t("admin.buildDate")}</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">{t("admin.trashRetention")}</span>
|
|
<span className="text-sm">
|
|
{(versionInfo?.trashRetentionDays ?? 0) > 0
|
|
? `${versionInfo?.trashRetentionDays} ${t("admin.days")}`
|
|
: t("admin.keepForever")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("admin.createNewUser")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.username")}</Label>
|
|
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t("admin.usernamePlaceholder")} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.password")}</Label>
|
|
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder={t("admin.minPasswordPlaceholder")} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.emailOptional")}</Label>
|
|
<Input type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} placeholder={t("admin.emailPlaceholder")} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.role")}</Label>
|
|
<Select value={newRole} onValueChange={(v) => setNewRole(v as "admin" | "user")}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.plan")}</Label>
|
|
<Select value={newTier} onValueChange={(v) => setNewTier(v as "free" | "premium" | "enterprise")}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>{t("common.cancel")}</Button>
|
|
<Button onClick={handleCreateUser} disabled={createUser.isPending || !newUsername || !newPassword}>
|
|
{createUser.isPending ? t("admin.creating") : t("admin.createUser")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("admin.editUser", { username: editUser?.username })}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.role")}</Label>
|
|
<Select
|
|
value={editUser?.role || "user"}
|
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, role: v } : null)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">{t("admin.roleUser")}</SelectItem>
|
|
<SelectItem value="admin">{t("admin.roleAdmin")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t("admin.plan")}</Label>
|
|
<Select
|
|
value={editUser?.tier || "free"}
|
|
onValueChange={(v) => setEditUser(editUser ? { ...editUser, tier: v } : null)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="free">{t("admin.planFree")}</SelectItem>
|
|
<SelectItem value="premium">{t("admin.planPremium")}</SelectItem>
|
|
<SelectItem value="enterprise">{t("admin.planEnterprise")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{editUser?.authProvider !== "oidc" ? (
|
|
<div className="space-y-2 border-t pt-4">
|
|
<Label>{t("admin.setPassword")}</Label>
|
|
<PasswordInput
|
|
value={editPassword}
|
|
onChange={(e) => setEditPassword(e.target.value)}
|
|
placeholder={t("admin.minPasswordPlaceholder")}
|
|
data-testid="input-set-password"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t("admin.resetsPassword")}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
|
{t("admin.idpManaged")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
|
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>{t("common.cancel")}</Button>
|
|
<Button
|
|
onClick={handleUpdateUser}
|
|
disabled={updateUser.isPending}
|
|
>
|
|
{t("common.save")}
|
|
</Button>
|
|
{editUser?.authProvider !== "oidc" && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleSetUserPassword}
|
|
disabled={setUserPassword.isPending || !editPassword}
|
|
>
|
|
{setUserPassword.isPending ? t("admin.setting") : t("admin.setPassword")}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!deleteConfirm} onOpenChange={(open) => !open && setDeleteConfirm(null)}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("admin.deleteUser")}</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm text-muted-foreground py-2">
|
|
{t("admin.deleteUserConfirm", { username: deleteConfirm?.username })}
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>{t("common.cancel")}</Button>
|
|
<Button variant="destructive" onClick={handleDeleteUser} disabled={deleteUser.isPending}>
|
|
{deleteUser.isPending ? t("detail.deleting") : t("common.delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Layout>
|
|
);
|
|
}
|