Initial commit
This commit is contained in:
35
ui/src/App.tsx
Normal file
35
ui/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Users from "./pages/Users";
|
||||
import Stores from "./pages/Stores";
|
||||
import Layout from "./components/Layout";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem("admin_token");
|
||||
return token ? <>{children}</> : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<PrivateRoute>
|
||||
<Layout />
|
||||
</PrivateRoute>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="stores" element={<Stores />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
46
ui/src/api/client.ts
Normal file
46
ui/src/api/client.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import axios from "axios";
|
||||
|
||||
// Laufzeit-Config hat Vorrang vor Build-Zeit
|
||||
declare global {
|
||||
interface Window {
|
||||
__RUNTIME_CONFIG__?: {
|
||||
API_URL: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL =
|
||||
window.__RUNTIME_CONFIG__?.API_URL ?? // ← Laufzeit (K8s ConfigMap)
|
||||
import.meta.env.VITE_API_URL ?? // ← Build-Zeit (Fallback)
|
||||
"http://localhost:8000"; // ← Dev Fallback
|
||||
|
||||
const api = axios.create({ baseURL: API_URL });
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("admin_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
export const adminApi = {
|
||||
getStats: () =>
|
||||
api.get("/admin/stats").then((r) => r.data),
|
||||
getUsers: () =>
|
||||
api.get("/admin/users").then((r) => r.data),
|
||||
getUserStores: (userId: string) =>
|
||||
api.get(`/admin/users/${userId}/stores`).then((r) => r.data),
|
||||
deleteStore: (userId: string, storeId: string) =>
|
||||
api.delete(`/admin/users/${userId}/stores/${storeId}`),
|
||||
rotateKey: (userId: string) =>
|
||||
api.post(`/admin/users/${userId}/rotate-key`).then((r) => r.data),
|
||||
getPermissions: (storeId: string) =>
|
||||
api.get(`/admin/stores/${storeId}/permissions`).then((r) => r.data),
|
||||
grantPermission: (storeId: string, userId: string, permission: string) =>
|
||||
api.post(`/admin/stores/${storeId}/permissions`, null, {
|
||||
params: { user_id: userId, permission },
|
||||
}),
|
||||
revokePermission: (storeId: string, userId: string) =>
|
||||
api.delete(`/admin/stores/${storeId}/permissions/${userId}`),
|
||||
};
|
||||
|
||||
export default api;
|
||||
61
ui/src/components/Layout.tsx
Normal file
61
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Outlet, NavLink, useNavigate } from "react-router-dom";
|
||||
import { LayoutDashboard, Users, Database, LogOut } from "lucide-react";
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("admin_token");
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/users", label: "Benutzer", icon: Users },
|
||||
{ to: "/stores", label: "Stores", icon: Database },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 bg-white shadow-md flex flex-col">
|
||||
<div className="p-5 border-b">
|
||||
<h1 className="font-bold text-lg">🗄️ Vector Admin</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition
|
||||
${isActive
|
||||
? "bg-blue-50 text-blue-700 font-medium"
|
||||
: "text-gray-600 hover:bg-gray-50"}`
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-6 py-4 text-sm
|
||||
text-gray-500 hover:text-red-600 border-t transition"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Abmelden
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
ui/src/components/PermissionModal.tsx
Normal file
152
ui/src/components/PermissionModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import { useState } from "react";
|
||||
import { X, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PermissionModal({ userId, onClose }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [newUser, setNewUser] = useState("");
|
||||
const [newPerm, setNewPerm] = useState("read");
|
||||
const [activeStore, setActiveStore] = useState<string | null>(null);
|
||||
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["user-stores", userId],
|
||||
queryFn: () => adminApi.getUserStores(userId),
|
||||
});
|
||||
|
||||
const { data: perms = [] } = useQuery({
|
||||
queryKey: ["perms", activeStore],
|
||||
queryFn: () => adminApi.getPermissions(activeStore!),
|
||||
enabled: !!activeStore,
|
||||
});
|
||||
|
||||
const grantMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
adminApi.grantPermission(activeStore!, newUser, newPerm),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["perms", activeStore] });
|
||||
setNewUser("");
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (uid: string) =>
|
||||
adminApi.revokePermission(activeStore!, uid),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: ["perms", activeStore] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b">
|
||||
<h2 className="font-bold text-lg">
|
||||
Berechtigungen — {userId}
|
||||
</h2>
|
||||
<button onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 grid grid-cols-2 gap-4">
|
||||
|
||||
{/* Stores-Liste */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||
STORES
|
||||
</h3>
|
||||
{stores.map((s: any) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveStore(s.id)}
|
||||
className={`w-full text-left p-3 rounded-lg mb-1 text-sm border transition
|
||||
${activeStore === s.id
|
||||
? "bg-blue-50 border-blue-400"
|
||||
: "hover:bg-gray-50 border-transparent"}`}
|
||||
>
|
||||
<p className="font-medium">{s.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{s.document_count} Dokumente
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2 text-sm text-gray-600">
|
||||
ZUGRIFF GEWÄHREN
|
||||
</h3>
|
||||
|
||||
{!activeStore ? (
|
||||
<p className="text-sm text-gray-400">Store auswählen →</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Bestehende Berechtigungen */}
|
||||
<div className="mb-3 space-y-1">
|
||||
{perms.map((p: any) => (
|
||||
<div
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs">{p.user_id}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium
|
||||
${p.permission === "admin" ? "bg-red-100 text-red-700" : ""}
|
||||
${p.permission === "write" ? "bg-yellow-100 text-yellow-700" : ""}
|
||||
${p.permission === "read" ? "bg-green-100 text-green-700" : ""}`}>
|
||||
{p.permission}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => revokeMutation.mutate(p.user_id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Neue Berechtigung */}
|
||||
<div className="border-t pt-3">
|
||||
<input
|
||||
placeholder="user_id"
|
||||
value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm mb-2"
|
||||
/>
|
||||
<select
|
||||
value={newPerm}
|
||||
onChange={(e) => setNewPerm(e.target.value)}
|
||||
className="w-full border rounded p-2 text-sm mb-2"
|
||||
>
|
||||
<option value="read">read</option>
|
||||
<option value="write">write</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => grantMutation.mutate()}
|
||||
disabled={!newUser}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded text-sm
|
||||
hover:bg-blue-700 disabled:opacity-50 flex items-center
|
||||
justify-center gap-1"
|
||||
>
|
||||
<Plus size={14} /> Zugriff gewähren
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
ui/src/index.css
Normal file
3
ui/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
39
ui/src/pages/Dashboard.tsx
Normal file
39
ui/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import { Database, Users, FileText, Key } from "lucide-react";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: stats, isLoading } = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: adminApi.getStats,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="p-6">Lade...</p>;
|
||||
|
||||
const cards = [
|
||||
{ label: "Gesamt Stores", value: stats?.total_stores, icon: Database, color: "blue" },
|
||||
{ label: "Gesamt User", value: stats?.total_users, icon: Users, color: "green" },
|
||||
{ label: "Gesamt Dokumente", value: stats?.total_documents, icon: FileText, color: "purple" },
|
||||
{ label: "Berechtigungen", value: stats?.total_permissions, icon: Key, color: "orange" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{cards.map(({ label, value, icon: Icon, color }) => (
|
||||
<div key={label} className="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
||||
<div className={`p-3 rounded-full bg-${color}-100`}>
|
||||
<Icon className={`text-${color}-600`} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{label}</p>
|
||||
<p className="text-2xl font-bold">{value ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
ui/src/pages/Login.tsx
Normal file
117
ui/src/pages/Login.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Login() {
|
||||
const [key, setKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!key.trim()) {
|
||||
setError("Bitte API Key eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/admin/verify", {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
localStorage.setItem("admin_token", key);
|
||||
navigate("/");
|
||||
|
||||
} else if (resp.status === 401) {
|
||||
setError("Ungültiger API Key");
|
||||
|
||||
} else if (resp.status === 403) {
|
||||
setError("Keine Admin-Berechtigung für diesen Key");
|
||||
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
setError(data?.detail || `Fehler ${resp.status}: Anmeldung fehlgeschlagen`);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
setError(`Verbindungsfehler: ${e}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="bg-white p-8 rounded-xl shadow-md w-96">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-5xl mb-4">🗄️</div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
Vector Store Admin
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm mt-1">
|
||||
Bitte mit Admin API Key anmelden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && !loading && handleLogin()}
|
||||
disabled={loading}
|
||||
className="w-full border rounded-lg p-3
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
disabled:opacity-50 disabled:bg-gray-50
|
||||
transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fehler */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-300 text-red-700
|
||||
rounded-lg p-3 text-sm mb-4 flex items-start gap-2">
|
||||
<span className="mt-0.5">❌</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button */}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !key.trim()}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg
|
||||
hover:bg-blue-700 transition font-medium
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4
|
||||
border-b-2 border-white" />
|
||||
Anmelden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🔐 Anmelden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
ui/src/pages/Stores.tsx
Normal file
235
ui/src/pages/Stores.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import PermissionModal from "../components/PermissionModal";
|
||||
import { Trash2, Shield, ChevronDown, ChevronUp, FileText } from "lucide-react";
|
||||
|
||||
interface Store {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
document_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
store_count: number;
|
||||
last_activity: string;
|
||||
}
|
||||
|
||||
export default function Stores() {
|
||||
const qc = useQueryClient();
|
||||
const [expandedUser, setExpandedUser] = useState<string | null>(null);
|
||||
const [selectedStore, setStore] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirm] = useState<{
|
||||
storeId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
|
||||
const { data: stores = [], isLoading: storesLoading } = useQuery({
|
||||
queryKey: ["user-stores", expandedUser],
|
||||
queryFn: () => adminApi.getUserStores(expandedUser!),
|
||||
enabled: !!expandedUser,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ userId, storeId }: { userId: string; storeId: string }) =>
|
||||
adminApi.deleteStore(userId, storeId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["user-stores", expandedUser] });
|
||||
qc.invalidateQueries({ queryKey: ["users"] });
|
||||
setConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const toggleUser = (userId: string) =>
|
||||
setExpandedUser(expandedUser === userId ? null : userId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center gap-2 text-gray-500">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
|
||||
Lade Stores...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Store-Verwaltung</h1>
|
||||
|
||||
{/* Lösch-Bestätigung */}
|
||||
{confirmDelete && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl p-6 w-96">
|
||||
<h2 className="font-bold text-lg mb-2">Store löschen?</h2>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Store{" "}
|
||||
<span className="font-mono font-semibold">
|
||||
{confirmDelete.name}
|
||||
</span>{" "}
|
||||
wird unwiderruflich gelöscht — inkl. aller Dokumente.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setConfirm(null)}
|
||||
className="px-4 py-2 rounded-lg border text-sm hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
deleteMutation.mutate({
|
||||
userId: confirmDelete.userId,
|
||||
storeId: confirmDelete.storeId,
|
||||
})
|
||||
}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm
|
||||
hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleteMutation.isPending ? "Lösche..." : "Löschen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User-Gruppen */}
|
||||
<div className="space-y-3">
|
||||
{users.length === 0 && (
|
||||
<p className="text-gray-400 text-sm">Keine Stores vorhanden.</p>
|
||||
)}
|
||||
|
||||
{users.map((user: User) => (
|
||||
<div
|
||||
key={user.user_id}
|
||||
className="bg-white rounded-xl shadow overflow-hidden"
|
||||
>
|
||||
{/* User-Header */}
|
||||
<button
|
||||
onClick={() => toggleUser(user.user_id)}
|
||||
className="w-full flex items-center justify-between p-4
|
||||
hover:bg-gray-50 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center
|
||||
justify-center text-blue-700 font-bold text-sm">
|
||||
{user.user_id.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm font-mono">
|
||||
{user.user_id}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{user.store_count} Store
|
||||
{user.store_count !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<span className="text-xs">
|
||||
{new Date(user.last_activity).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
{expandedUser === user.user_id
|
||||
? <ChevronUp size={18} />
|
||||
: <ChevronDown size={18} />
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Store-Liste */}
|
||||
{expandedUser === user.user_id && (
|
||||
<div className="border-t">
|
||||
{storesLoading ? (
|
||||
<div className="p-4 flex items-center gap-2 text-gray-400 text-sm">
|
||||
<div className="animate-spin rounded-full h-4 w-4
|
||||
border-b-2 border-blue-600" />
|
||||
Lade Stores...
|
||||
</div>
|
||||
) : stores.length === 0 ? (
|
||||
<p className="p-4 text-sm text-gray-400">
|
||||
Keine Stores gefunden.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-xs text-gray-500 uppercase">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">Name</th>
|
||||
<th className="text-left px-4 py-2">Dokumente</th>
|
||||
<th className="text-left px-4 py-2">Erstellt</th>
|
||||
<th className="text-left px-4 py-2">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((store: Store) => (
|
||||
<tr
|
||||
key={store.id}
|
||||
className="border-t hover:bg-gray-50 transition"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
{store.name}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<FileText size={14} />
|
||||
{store.document_count}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||
{new Date(store.created_at).toLocaleString("de-DE")}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setStore(store.id)}
|
||||
title="Berechtigungen verwalten"
|
||||
className="p-1.5 bg-blue-100 text-blue-700
|
||||
rounded hover:bg-blue-200 transition"
|
||||
>
|
||||
<Shield size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
storeId: store.id,
|
||||
userId: user.user_id,
|
||||
name: store.name,
|
||||
})
|
||||
}
|
||||
title="Store löschen"
|
||||
className="p-1.5 bg-red-100 text-red-700
|
||||
rounded hover:bg-red-200 transition"
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Permission Modal */}
|
||||
{selectedStore && (
|
||||
<PermissionModal
|
||||
userId={expandedUser!}
|
||||
onClose={() => setStore(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
ui/src/pages/Users.tsx
Normal file
92
ui/src/pages/Users.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { adminApi } from "../api/client";
|
||||
import PermissionModal from "../components/PermissionModal";
|
||||
import { RefreshCw, Trash2, Shield } from "lucide-react";
|
||||
|
||||
export default function Users() {
|
||||
const qc = useQueryClient();
|
||||
const [selectedStore, setStore] = useState<string | null>(null);
|
||||
const [newKey, setNewKey] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: adminApi.getUsers,
|
||||
});
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: (userId: string) => adminApi.rotateKey(userId),
|
||||
onSuccess: (data) => setNewKey(data.new_key),
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="p-6">Lade User...</p>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Benutzerverwaltung</h1>
|
||||
|
||||
{/* Neuer Key Dialog */}
|
||||
{newKey && (
|
||||
<div className="mb-4 p-4 bg-green-50 border border-green-300 rounded-lg">
|
||||
<p className="font-semibold text-green-800">✅ Neuer API Key:</p>
|
||||
<code className="block mt-1 text-sm bg-white p-2 rounded border">
|
||||
{newKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => setNewKey(null)}
|
||||
className="mt-2 text-sm text-green-700 underline"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-xl shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-4">User ID</th>
|
||||
<th className="text-left p-4">Stores</th>
|
||||
<th className="text-left p-4">Letzte Aktivität</th>
|
||||
<th className="text-left p-4">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u: any) => (
|
||||
<tr key={u.user_id} className="border-t hover:bg-gray-50">
|
||||
<td className="p-4 font-mono text-xs">{u.user_id}</td>
|
||||
<td className="p-4">{u.store_count}</td>
|
||||
<td className="p-4 text-gray-500">
|
||||
{new Date(u.last_activity).toLocaleString("de-DE")}
|
||||
</td>
|
||||
<td className="p-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setStore(u.user_id)}
|
||||
title="Berechtigungen"
|
||||
className="p-1.5 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
<Shield size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rotateMutation.mutate(u.user_id)}
|
||||
title="Key rotieren"
|
||||
className="p-1.5 bg-yellow-100 text-yellow-700 rounded hover:bg-yellow-200"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedStore && (
|
||||
<PermissionModal
|
||||
userId={selectedStore}
|
||||
onClose={() => setStore(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user