Initial commit

This commit is contained in:
root
2026-04-29 08:17:35 +00:00
commit ef55253cbd
49 changed files with 3073 additions and 0 deletions

21
ui/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json .
RUN npm install
COPY index.html .
COPY vite.config.ts .
COPY tsconfig.json .
COPY tailwind.config.js .
COPY postcss.config.js .
COPY src/ ./src/
RUN npm run build
FROM node:20-alpine AS runtime
RUN npm install -g serve
COPY --from=builder /build/dist /app
COPY docker-entrypoint.sh /
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]

11
ui/docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
cat > /app/config.js << EOF
window.__RUNTIME_CONFIG__ = {
API_URL: "${API_URL:-http://localhost:8000}"
};
EOF
echo "✅ Config generiert: API_URL=${API_URL}"
exec serve -s /app -l 80

13
ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vector Store Admin</title>
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
ui/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "vector-store-admin",
"version": "1.0.0",
"type": "module",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"@tanstack/react-query": "^5.28.0",
"axios": "^1.6.7",
"lucide-react": "^0.368.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.4.0",
"vite": "^5.2.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
}

6
ui/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

35
ui/src/App.tsx Normal file
View 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
View 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;

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

View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
ui/src/main.tsx Normal file
View 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>
);

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

5
ui/tailwind.config.js Normal file
View File

@@ -0,0 +1,5 @@
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};

12
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}

10
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: {
outDir: "dist",
sourcemap: false,
},
});