This commit is contained in:
root
2025-10-27 14:14:57 +00:00
parent f0b1ca4b68
commit a76567969a
27 changed files with 2612 additions and 378 deletions
-1
View File
@@ -1,3 +1,2 @@
export * from "@/pages/auth/sign-in";
export * from "@/pages/auth/sign-up";
export * from "@/pages/auth/regsuperuser";
-190
View File
@@ -1,190 +0,0 @@
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import {
Card,
CardHeader,
CardBody,
Typography,
Input,
Button,
Alert,
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
export function RegSuperuser({ onCompleted }) {
const navigate = useNavigate();
const [checkingStatus, setCheckingStatus] = useState(true);
const [form, setForm] = useState({
username: "",
email: "",
password: "",
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleChange = (field) => (event) => {
setForm((prev) => ({ ...prev, [field]: event.target.value }));
};
useEffect(() => {
let isActive = true;
const controller = new AbortController();
const verifyStatus = async () => {
try {
const response = await fetch("/api/auth/superuser/status", { signal: controller.signal });
if (!response.ok) {
throw new Error("STATUS_REQUEST_FAILED");
}
const data = await response.json();
if (data.exists && isActive) {
navigate("/dashboard/stacks", { replace: true });
}
} catch (err) {
if (err.name !== "AbortError") {
console.error("⚠️ [Superuser] Statusabfrage fehlgeschlagen:", err);
}
} finally {
if (isActive) {
setCheckingStatus(false);
}
}
};
verifyStatus();
return () => {
isActive = false;
controller.abort();
};
}, [navigate]);
const handleSubmit = async (event) => {
event.preventDefault();
if (loading) return;
setLoading(true);
setError(null);
setSuccess(false);
try {
const response = await fetch("/api/auth/superuser/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
switch (payload.error) {
case "MISSING_FIELDS":
throw new Error("Bitte alle Felder ausfüllen.");
case "USERNAME_REQUIRED":
throw new Error("Benutzername darf nicht leer sein.");
case "EMAIL_INVALID":
throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
case "PASSWORD_TOO_SHORT":
throw new Error("Passwort muss mindestens 8 Zeichen enthalten.");
case "SUPERUSER_EXISTS":
throw new Error("Superuser existiert bereits.");
default:
throw new Error("Registrierung fehlgeschlagen. Bitte erneut versuchen.");
}
}
setSuccess(true);
if (typeof onCompleted === "function") {
onCompleted();
} else {
navigate("/dashboard/stacks", { replace: true });
}
} catch (err) {
setError(err.message || "Unbekannter Fehler");
} finally {
setLoading(false);
}
};
if (checkingStatus) {
return (
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
<span className="text-blue-gray-500">Pruefe Superuser-Status ...</span>
</div>
);
}
return (
<div className="flex min-h-screen w-full items-center justify-center py-10">
<Card className="w-full max-w-lg border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
className="mb-0 grid place-items-start gap-2 rounded-none bg-transparent p-6"
>
<Typography variant="h4" color="blue-gray">
Superuser anlegen
</Typography>
<Typography color="gray" className="font-normal">
Lege den ersten Benutzer deines Systems an. Dieser verfügt über uneingeschränkte Rechte
und kann später weitere Benutzer verwalten.
</Typography>
</CardHeader>
<CardBody className="pt-0">
<form className="mt-4 flex flex-col gap-6" onSubmit={handleSubmit}>
<Input
label="Benutzername"
value={form.username}
onChange={handleChange("username")}
required
disabled={loading || success}
/>
<Input
label="E-Mail-Adresse"
type="email"
value={form.email}
onChange={handleChange("email")}
required
disabled={loading || success}
/>
<Input
label="Passwort"
type="password"
value={form.password}
onChange={handleChange("password")}
required
disabled={loading || success}
/>
{error && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{error}
</Alert>
)}
{success && (
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
Superuser wurde erfolgreich angelegt. Du wirst gleich weitergeleitet.
</Alert>
)}
<Button type="submit" color="blue" disabled={loading || success}>
{loading ? "Wird angelegt..." : "Superuser erstellen"}
</Button>
</form>
</CardBody>
</Card>
</div>
);
}
RegSuperuser.propTypes = {
onCompleted: PropTypes.func,
};
RegSuperuser.defaultProps = {
onCompleted: undefined,
};
export default RegSuperuser;
+161 -30
View File
@@ -1,29 +1,165 @@
import { useCallback, useEffect, useState } from "react";
import {
Card,
Input,
Checkbox,
Button,
Typography,
Alert,
} from "@material-tailwind/react";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
export function SignIn() {
const navigate = useNavigate();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [statusChecked, setStatusChecked] = useState(false);
useEffect(() => {
let isActive = true;
const controller = new AbortController();
const initialize = async () => {
try {
const setupResponse = await fetch("/api/setup/status", {
credentials: "include",
signal: controller.signal,
});
if (setupResponse.ok) {
const setupData = await setupResponse.json();
if (!setupData.setupComplete && isActive) {
navigate("/setup", { replace: true });
return;
}
}
const sessionResponse = await fetch("/api/auth/session", {
credentials: "include",
signal: controller.signal,
});
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
if (sessionData?.user && isActive) {
navigate("/dashboard/stacks", { replace: true });
return;
}
}
} catch (err) {
if (err.name !== "AbortError") {
console.error("⚠️ [Auth] Initiale Prüfung fehlgeschlagen:", err);
}
} finally {
if (isActive) {
setStatusChecked(true);
}
}
};
initialize();
return () => {
isActive = false;
controller.abort();
};
}, [navigate]);
const handleSubmit = useCallback(
async (event) => {
event.preventDefault();
if (loading) return;
setError(null);
const trimmedIdentifier = identifier.trim();
const trimmedPassword = password.trim();
if (!trimmedIdentifier || !trimmedPassword) {
setError("Bitte fülle beide Felder aus.");
return;
}
setLoading(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ identifier: trimmedIdentifier, password: trimmedPassword }),
});
if (response.status === 403) {
navigate("/setup", { replace: true });
return;
}
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
if (response.status === 401 || payload.error === "INVALID_CREDENTIALS") {
setError("Ungültige Zugangsdaten.");
} else if (payload.error === "MISSING_CREDENTIALS") {
setError("Bitte fülle beide Felder aus.");
} else if (payload.error === "SETUP_REQUIRED") {
navigate("/setup", { replace: true });
return;
} else {
setError("Anmeldung fehlgeschlagen. Bitte versuche es erneut.");
}
return;
}
await response.json().catch(() => ({}));
navigate("/dashboard/stacks", { replace: true });
} catch (err) {
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
setError("Netzwerkfehler bitte erneut versuchen.");
} finally {
setLoading(false);
}
},
[identifier, password, loading, navigate]
);
if (!statusChecked) {
return (
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
<span className="text-blue-gray-500">Pruefe Anmeldestatus ...</span>
</div>
);
}
return (
<section className="m-8 flex gap-4">
<div className="w-full lg:w-3/5 mt-24">
<div className="text-center">
<Typography variant="h2" className="font-bold mb-4">Anmelden</Typography>
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Geben Sie Ihre eMail oder Ihren Benutzernamen und Ihr Passwort ein, um sich anzumelden.</Typography>
<Typography variant="h2" className="font-bold mb-4">
Anmelden
</Typography>
<Typography
variant="paragraph"
color="blue-gray"
className="text-lg font-normal"
>
Gib deine E-Mail oder deinen Benutzernamen sowie dein Passwort ein, um dich anzumelden.
</Typography>
</div>
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
<form
className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2"
onSubmit={handleSubmit}
>
<div className="mb-1 flex flex-col gap-6">
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
eMail oder Benutzername
E-Mail oder Benutzername
</Typography>
<Input
size="lg"
placeholder="eMail oder Benutzername"
placeholder="E-Mail oder Benutzername"
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
required
autoFocus
disabled={loading}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
@@ -33,39 +169,34 @@ export function SignIn() {
Passwort
</Typography>
<Input
type="passwort"
type="password"
size="lg"
placeholder="********"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
disabled={loading}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
/>
</div>
<Button className="mt-6" fullWidth>
Anmelden
</Button>
<div className="flex items-center justify-between gap-2 mt-6">
<Typography variant="small" className="font-medium text-gray-900">
<a href="#">
Forgot Password
</a>
</Typography>
</div>
{/* <Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
Not registered?
<Link to="/auth/sign-up" className="text-gray-900 ml-1">Create account</Link>
</Typography> */}
</form>
{error && (
<Alert color="red" className="mt-2 border border-red-200 bg-red-50 text-red-700">
{error}
</Alert>
)}
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
{loading ? "Anmeldung läuft ..." : "Anmelden"}
</Button>
</form>
</div>
<div className="w-2/5 h-full hidden lg:block">
<img
src="/img/pattern.png"
className="h-full w-full object-cover rounded-3xl"
/>
<img src="/img/pattern.png" className="h-full w-full object-cover rounded-3xl" />
</div>
</section>
);
}
+2
View File
@@ -1,3 +1,5 @@
export * from "@/pages/dashboard/stacks";
export * from "@/pages/dashboard/maintenance";
export * from "@/pages/dashboard/logs";
export * from "@/pages/dashboard/users";
export * from "@/pages/dashboard/usergroups";
+354 -2
View File
@@ -9,7 +9,8 @@ import {
CardHeader,
CardBody,
Button,
Switch
Switch,
Input
} from "@material-tailwind/react";
const UPDATE_STATUS_LABELS = {
@@ -125,6 +126,10 @@ export function Maintenance() {
deleteSshConfig,
testSshConnection,
fetchSuperuserStatus,
fetchSetupStatus,
deleteSetupEndpoint,
deleteSetupServer,
updateSetupApiKey,
removeSuperuserAccount
} = useMaintenance();
@@ -189,6 +194,13 @@ export function Maintenance() {
const [superuserSummary, setSuperuserSummary] = useState(null);
const [superuserStatusError, setSuperuserStatusError] = useState("");
const [superuserDeleteLoading, setSuperuserDeleteLoading] = useState(false);
const [setupResources, setSetupResources] = useState(null);
const [setupResourcesLoading, setSetupResourcesLoading] = useState(true);
const [setupResourcesError, setSetupResourcesError] = useState("");
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
const [endpointDeleteId, setEndpointDeleteId] = useState(null);
const [duplicates, setDuplicates] = useState([]);
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
@@ -226,6 +238,30 @@ export function Maintenance() {
}
}, [fetchSuperuserStatus, showToast]);
const loadSetupResources = useCallback(async ({ silent = false } = {}) => {
if (!silent) {
setSetupResourcesLoading(true);
}
setSetupResourcesError("");
try {
const data = await fetchSetupStatus();
setSetupResources(data);
setApiKeyDrafts({});
} catch (err) {
const message = err.response?.data?.error || err.message || "Setup-Daten konnten nicht geladen werden";
setSetupResourcesError(message);
if (!silent) {
showToast({
variant: "error",
title: "Setup-Daten",
description: message
});
}
} finally {
setSetupResourcesLoading(false);
}
}, [fetchSetupStatus, showToast]);
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
if (portainerRequestRef.current) {
return portainerRequestRef.current;
@@ -318,6 +354,10 @@ export function Maintenance() {
loadSuperuserStatus({ silent: true });
}, [loadSuperuserStatus]);
useEffect(() => {
loadSetupResources();
}, [loadSetupResources]);
useEffect(() => {
fetchPortainerStatus();
}, [fetchPortainerStatus]);
@@ -326,6 +366,14 @@ export function Maintenance() {
fetchDuplicates();
}, [fetchDuplicates]);
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const setupEndpoints = useMemo(() => setupResources?.endpoints?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
const duplicateCount = Array.isArray(duplicates)
@@ -499,6 +547,135 @@ export function Maintenance() {
setSuperuserDeleteLoading(false);
}
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleDeleteEndpoint = useCallback(async (endpointId) => {
if (endpointDeleteId === endpointId) {
return;
}
const target = setupEndpoints.find((entry) => entry.id === endpointId);
if (!target) {
return;
}
const label = target.name || `Endpoint ${target.external_id}`;
const confirmMessage = `Endpoint "${label}" wirklich löschen?${target.is_default ? "\nDieser Endpoint ist aktuell als Standard markiert." : ""}`;
if (typeof window !== "undefined") {
const confirmed = window.confirm(confirmMessage);
if (!confirmed) {
return;
}
}
setEndpointDeleteId(endpointId);
try {
await deleteSetupEndpoint(endpointId);
showToast({
variant: "success",
title: "Endpoint gelöscht",
description: `Endpoint "${label}" wurde entfernt.`
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Endpoint konnte nicht gelöscht werden.";
showToast({
variant: "error",
title: "Endpoint löschen fehlgeschlagen",
description: message
});
} finally {
setEndpointDeleteId(null);
}
}, [deleteSetupEndpoint, endpointDeleteId, loadSetupResources, setupEndpoints, showToast]);
const handleDeleteServer = useCallback(async (serverId) => {
if (serverDeleteId === serverId) {
return;
}
const target = setupServers.find((entry) => entry.id === serverId);
if (!target) {
return;
}
const linkedEndpoints = setupEndpoints.filter((entry) => entry.server_id === serverId);
const label = target.name || target.url || `Server ${serverId}`;
const confirmMessage = [
`Server "${label}" wirklich löschen?`,
linkedEndpoints.length ? `Dabei werden ${linkedEndpoints.length} zugeordnete Endpoint(s) entfernt.` : null,
"Das System benötigt anschließend erneut einen gültigen Server/Endpoint im Setup."
].filter(Boolean).join("\n");
if (typeof window !== "undefined") {
const confirmed = window.confirm(confirmMessage);
if (!confirmed) {
return;
}
}
setServerDeleteId(serverId);
try {
await deleteSetupServer(serverId);
showToast({
variant: "success",
title: "Server gelöscht",
description: `Server "${label}" wurde entfernt.`
});
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "Server konnte nicht gelöscht werden.";
showToast({
variant: "error",
title: "Server löschen fehlgeschlagen",
description: message
});
} finally {
setServerDeleteId(null);
}
}, [deleteSetupServer, loadSetupResources, serverDeleteId, setupEndpoints, setupServers, showToast]);
const handleApiKeyDraftChange = useCallback((serverId, value) => {
setApiKeyDrafts((prev) => ({
...prev,
[serverId]: value
}));
}, []);
const handleApiKeyUpdate = useCallback(async (serverId) => {
const draft = (apiKeyDrafts[serverId] ?? "").trim();
if (!draft) {
showToast({
variant: "warning",
title: "API-Key fehlt",
description: "Bitte gib einen API-Key ein."
});
return;
}
if (apiKeyUpdatingId === serverId) {
return;
}
setApiKeyUpdatingId(serverId);
try {
await updateSetupApiKey(serverId, draft);
showToast({
variant: "success",
title: "API-Key aktualisiert",
description: "Der API-Key wurde gespeichert."
});
setApiKeyDrafts((prev) => ({
...prev,
[serverId]: ""
}));
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.error || err.message || "API-Key konnte nicht aktualisiert werden.";
showToast({
variant: "error",
title: "Aktualisierung fehlgeschlagen",
description: message
});
} finally {
setApiKeyUpdatingId(null);
}
}, [apiKeyDrafts, apiKeyUpdatingId, loadSetupResources, showToast, updateSetupApiKey]);
const handleMaintenanceToggle = useCallback(async (nextActive) => {
if (maintenanceLoading || maintenanceToggleLoading) return;
if (nextActive === maintenanceActive) return;
@@ -706,6 +883,181 @@ export function Maintenance() {
{maintenanceToggleLoading && (
<p className="text-xs text-stormGrey-500">Wartungsmodus wird aktualisiert</p>
)}
</CardBody>
</Card>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; Endpoints</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{setupResourcesLoading && (
<p className="text-sm text-stormGrey-500">Daten werden geladen</p>
)}
{!setupResourcesLoading && setupResourcesError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{setupResourcesError}
</Alert>
)}
{!setupResourcesLoading && !setupResourcesError && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Server
</Typography>
<Typography variant="small" color="blue-gray">
{setupServers.length} vorhanden
</Typography>
</div>
{setupServers.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Server hinterlegt.
</p>
) : (
<div className="flex flex-col gap-3">
{setupServers.map((server) => {
const apiKeyInfo = apiKeyInfoMap.get(server.id) || null;
const hasKey = Boolean(apiKeyInfo?.hasKey);
const apiKeyUpdatedLabel = apiKeyInfo?.updatedAt
? new Date(apiKeyInfo.updatedAt).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: null;
return (
<div
key={server.id}
className="flex flex-col gap-3 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-start md:justify-between"
>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-stormGrey-900">
{server.name || "Ohne Namen"}
</p>
<p className="text-xs text-stormGrey-500 break-all">
{server.url}
</p>
<p className="text-xs text-stormGrey-500">
API-Key: {hasKey ? "vorhanden" : "nicht hinterlegt"}{apiKeyUpdatedLabel ? ` (aktualisiert am ${apiKeyUpdatedLabel})` : ""}
</p>
</div>
<div className="flex w-full flex-col gap-2 md:max-w-xs">
<Input
type="password"
label="Neuer API-Key"
value={apiKeyDrafts[server.id] ?? ""}
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
disabled={setupResourcesLoading || apiKeyUpdatingId === server.id}
/>
<div className="flex items-center gap-2 justify-end">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={setupResourcesLoading || serverDeleteId === server.id || endpointDeleteId !== null || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
<Button
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Endpoints
</Typography>
<Typography variant="small" color="blue-gray">
{setupEndpoints.length} vorhanden
</Typography>
</div>
{setupEndpoints.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Endpoints hinterlegt.
</p>
) : (
<div className="flex flex-col gap-3">
{setupEndpoints.map((endpoint) => (
<div
key={endpoint.id}
className="flex flex-col gap-2 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-center md:justify-between"
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-stormGrey-900">
{endpoint.name || `Endpoint ${endpoint.external_id}`}
</p>
{endpoint.is_default ? (
<span className="rounded-full bg-arcticBlue-100 px-2 py-0.5 text-[10px] font-semibold text-arcticBlue-700">
Standard
</span>
) : null}
</div>
<p className="text-xs text-stormGrey-500">
ID: {endpoint.external_id} · Server: {endpoint.server_name || endpoint.server_url}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteEndpoint(endpoint.id)}
disabled={setupResourcesLoading || endpointDeleteId === endpoint.id || serverDeleteId !== null}
>
{endpointDeleteId === endpoint.id ? "Lösche…" : "Löschen"}
</Button>
</div>
</div>
))}
</div>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server, Endpoint und API-Key erneut festzulegen.
</Alert>
)}
<div className="flex items-center justify-end">
<Button
variant="outlined"
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={setupResourcesLoading}
>
Aktualisieren
</Button>
</div>
</div>
)}
</CardBody>
</Card>
@@ -1288,4 +1640,4 @@ export function Maintenance() {
}
export default Maintenance;
export default Maintenance;
@@ -0,0 +1,29 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
export function Usergroups() {
}
export default Usergroups;
+28
View File
@@ -0,0 +1,28 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
CardHeader,
CardBody,
Typography,
Avatar,
Chip,
Tooltip,
Progress,
Collapse,
Button,
ButtonGroup,
Select,
Option,
Input,
useSelect
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
export function Users() {
}
export default Users;
+1
View File
@@ -0,0 +1 @@
export { Setup as default, Setup } from "./setup";
+471
View File
@@ -0,0 +1,471 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Card,
CardHeader,
CardBody,
Typography,
Input,
Button,
Alert,
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
const initialFormState = {
superuser: {
username: "",
email: "",
password: "",
},
server: {
name: "",
url: "",
},
endpoint: {
name: "",
externalId: "",
serverId: "",
},
apiKey: ""
};
export function Setup() {
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [loadingStatus, setLoadingStatus] = useState(true);
const [fetchError, setFetchError] = useState(null);
const [form, setForm] = useState(initialFormState);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [submitSuccess, setSubmitSuccess] = useState(false);
useEffect(() => {
let isActive = true;
const controller = new AbortController();
const loadStatus = async () => {
setLoadingStatus(true);
setFetchError(null);
try {
const response = await fetch("/api/setup/status", {
signal: controller.signal,
credentials: "include"
});
if (!response.ok) {
throw new Error("STATUS_REQUEST_FAILED");
}
const data = await response.json();
if (!isActive) return;
setStatus(data);
if (data.setupComplete) {
navigate("/auth/sign-in", { replace: true });
return;
}
setForm((prev) => ({
superuser: {
username: data.envDefaults?.superuserUsername?.length ? data.envDefaults.superuserUsername : prev.superuser.username,
email: data.envDefaults?.superuserEmail?.length ? data.envDefaults.superuserEmail : prev.superuser.email,
password: data.envDefaults?.superuserPassword?.length ? data.envDefaults.superuserPassword : prev.superuser.password,
},
server: {
name: data.envDefaults?.serverName || prev.server.name,
url: data.envDefaults?.serverUrl?.length ? data.envDefaults.serverUrl : prev.server.url,
},
endpoint: {
name: data.envDefaults?.endpointName || prev.endpoint.name,
externalId: data.envDefaults?.endpointExternalId?.length ? data.envDefaults.endpointExternalId : prev.endpoint.externalId,
serverId: prev.endpoint.serverId || ""
},
apiKey: data.envDefaults?.apiKeyValue?.length ? data.envDefaults.apiKeyValue : prev.apiKey
}));
} catch (error) {
if (error.name === "AbortError") return;
console.error("⚠️ [Setup] Status konnte nicht geladen werden:", error);
if (isActive) {
setFetchError("Setup-Status konnte nicht geladen werden. Bitte Seite aktualisieren.");
}
} finally {
if (isActive) {
setLoadingStatus(false);
}
}
};
loadStatus();
return () => {
isActive = false;
controller.abort();
};
}, [navigate]);
const envDefaults = status?.envDefaults || {};
const envSuperuserUsername = envDefaults.superuserUsername ?? "";
const envSuperuserEmail = envDefaults.superuserEmail ?? "";
const envSuperuserPassword = envDefaults.superuserPassword ?? "";
const envServerUrl = envDefaults.serverUrl ?? "";
const envEndpointExternalId = envDefaults.endpointExternalId ?? "";
const envApiKeyValue = envDefaults.apiKeyValue ?? "";
const requireSuperuser = Boolean(status?.requirements?.superuser);
const requireServer = Boolean(status?.requirements?.server);
const requireEndpoint = Boolean(status?.requirements?.endpoint);
const requireApiKey = Boolean(status?.requirements?.apiKey);
const hasAnyApiKey = Boolean(status?.apiKeys?.count);
const serverEnvProvided = Boolean(envServerUrl || status?.servers?.envProvided);
const endpointEnvProvided = Boolean(envEndpointExternalId || status?.endpoints?.envProvided);
const apiKeyEnvProvided = Boolean(envApiKeyValue || status?.apiKeys?.envProvided);
const showServerSection = requireServer || serverEnvProvided;
const showEndpointSection = requireEndpoint || endpointEnvProvided;
const showApiKeyField = requireApiKey || !hasAnyApiKey || apiKeyEnvProvided;
const superuserUsernameReadOnly = Boolean(envSuperuserUsername);
const superuserEmailReadOnly = Boolean(envSuperuserEmail);
const superuserPasswordReadOnly = Boolean(envSuperuserPassword);
const serverNameReadOnly = Boolean(envDefaults.serverNameFromEnv);
const serverUrlReadOnly = Boolean(envServerUrl);
const endpointNameReadOnly = Boolean(envDefaults.endpointNameFromEnv);
const endpointExternalIdReadOnly = Boolean(envEndpointExternalId);
const apiKeyReadOnly = Boolean(envApiKeyValue);
const handleInputChange = useCallback((section, field, { locked = false } = {}) => (event) => {
if (locked) {
return;
}
const value = event.target.value;
setForm((prev) => ({
...prev,
[section]: {
...prev[section],
[field]: value
}
}));
}, []);
const validationErrors = useMemo(() => {
if (!status) return [];
const errors = [];
if (requireSuperuser) {
const { username, email, password } = form.superuser;
if (!username.trim()) errors.push("Benutzername ist erforderlich.");
if (!email.trim()) errors.push("E-Mail-Adresse ist erforderlich.");
if (!password.trim()) errors.push("Passwort ist erforderlich.");
}
if (requireServer && !form.server.url.trim()) {
errors.push("Server-URL ist erforderlich.");
}
if (requireEndpoint && !form.endpoint.externalId.trim()) {
errors.push("Endpoint-ID ist erforderlich.");
}
if (showApiKeyField && requireApiKey && !form.apiKey.trim()) {
errors.push("API-Key ist erforderlich.");
}
return errors;
}, [form, requireSuperuser, requireServer, requireEndpoint, showApiKeyField, requireApiKey, status]);
const handleSubmit = useCallback(async (event) => {
event.preventDefault();
if (submitting || !status) return;
setSubmitError(null);
setSubmitSuccess(false);
if (validationErrors.length) {
setSubmitError(validationErrors.join(" "));
return;
}
const payload = {};
if (requireSuperuser) {
payload.superuser = {
username: form.superuser.username.trim(),
email: form.superuser.email.trim(),
password: form.superuser.password
};
}
if (showServerSection) {
const serverName = form.server.name.trim();
const serverUrl = form.server.url.trim();
if (requireServer || (!serverNameReadOnly && serverName) || (!serverUrlReadOnly && serverUrl)) {
payload.server = {
name: serverName,
url: serverUrl
};
}
}
if (showEndpointSection) {
const endpointName = form.endpoint.name.trim();
const endpointExternalId = form.endpoint.externalId.trim();
const endpointServerId = form.endpoint.serverId ? Number(form.endpoint.serverId) : undefined;
if (requireEndpoint || (!endpointNameReadOnly && endpointName) || (!endpointExternalIdReadOnly && endpointExternalId) || endpointServerId) {
payload.endpoint = {
name: endpointName || null,
externalId: endpointExternalId || null,
serverId: endpointServerId
};
}
}
const apiKeyValue = form.apiKey.trim();
if (showApiKeyField || apiKeyValue) {
payload.apiKey = { value: apiKeyValue };
}
try {
setSubmitting(true);
const response = await fetch("/api/setup/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
switch (payload.error) {
case "SERVER_DETAILS_REQUIRED":
throw new Error("Bitte gib eine gültige Server-URL an.");
case "ENDPOINT_DETAILS_REQUIRED":
throw new Error("Bitte gib eine gültige Endpoint-ID an.");
case "API_KEY_REQUIRED":
throw new Error("Bitte gib einen gültigen API-Key an.");
case "USERNAME_REQUIRED":
throw new Error("Benutzername wird benötigt.");
case "EMAIL_INVALID":
throw new Error("Bitte gib eine gültige E-Mail-Adresse an.");
case "PASSWORD_TOO_SHORT":
throw new Error("Passwort muss mindestens 8 Zeichen enthalten.");
case "INVALID_PASSWORD":
throw new Error("Das Passwort ist ungültig.");
case "API_KEY_ENCRYPT_FAILED":
throw new Error("API-Key konnte nicht verschlüsselt werden. Bitte erneut versuchen.");
case "SUPERUSER_EXISTS":
throw new Error("Der Superuser wurde bereits angelegt.");
default:
throw new Error("Setup konnte nicht abgeschlossen werden. Bitte erneut versuchen.");
}
}
const result = await response.json().catch(() => ({}));
setStatus(result.status);
setSubmitSuccess(true);
setTimeout(() => {
navigate("/auth/sign-in", { replace: true });
}, 1500);
} catch (error) {
console.error("⚠️ [Setup] Abschluss fehlgeschlagen:", error);
setSubmitError(error.message || "Setup fehlgeschlagen.");
} finally {
setSubmitting(false);
}
}, [form, navigate, requireSuperuser, requireServer, requireEndpoint, showServerSection, showEndpointSection, showApiKeyField, status, submitting, validationErrors, serverNameReadOnly, serverUrlReadOnly, endpointNameReadOnly, endpointExternalIdReadOnly]);
if (loadingStatus) {
return (
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
<span className="text-blue-gray-500">Lade Setup-Status ...</span>
</div>
);
}
if (fetchError) {
return (
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
<Alert color="red" className="w-full max-w-lg border border-red-200 bg-red-50 text-red-700">
{fetchError}
</Alert>
</div>
);
}
if (!status) {
return (
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
<span className="text-blue-gray-500">Keine Setup-Informationen verfügbar.</span>
</div>
);
}
const superuserSection = requireSuperuser ? (
<>
<Typography variant="h6" color="blue-gray" className="font-semibold">
Superuser
</Typography>
<div className="grid grid-cols-1 gap-4">
<Input
label="Benutzername"
required
value={form.superuser.username}
onChange={handleInputChange("superuser", "username", { locked: superuserUsernameReadOnly })}
disabled={submitting}
readOnly={superuserUsernameReadOnly}
/>
<Input
label="E-Mail-Adresse"
required
type="email"
value={form.superuser.email}
onChange={handleInputChange("superuser", "email", { locked: superuserEmailReadOnly })}
disabled={submitting}
readOnly={superuserEmailReadOnly}
/>
<Input
label="Passwort"
required
type="password"
value={form.superuser.password}
onChange={handleInputChange("superuser", "password", { locked: superuserPasswordReadOnly })}
disabled={submitting}
readOnly={superuserPasswordReadOnly}
/>
</div>
</>
) : (
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
Superuser ist bereits vorhanden.
</Alert>
);
const serverSection = showServerSection ? (
<>
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
Server
</Typography>
<div className="grid grid-cols-1 gap-4">
<Input
label="Server-Name"
value={form.server.name}
onChange={handleInputChange("server", "name", { locked: serverNameReadOnly })}
readOnly={serverNameReadOnly}
disabled={submitting}
/>
<Input
label="Server-URL"
required={requireServer}
value={form.server.url}
onChange={handleInputChange("server", "url", { locked: serverUrlReadOnly })}
readOnly={serverUrlReadOnly}
disabled={submitting}
/>
</div>
</>
) : null;
const endpointSection = showEndpointSection ? (
<>
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
Endpoint
</Typography>
<div className="grid grid-cols-1 gap-4">
<Input
label="Endpoint-Name"
value={form.endpoint.name}
onChange={handleInputChange("endpoint", "name", { locked: endpointNameReadOnly })}
readOnly={endpointNameReadOnly}
disabled={submitting}
/>
<Input
label="Endpoint-ID"
required={requireEndpoint}
value={form.endpoint.externalId}
onChange={handleInputChange("endpoint", "externalId", { locked: endpointExternalIdReadOnly })}
readOnly={endpointExternalIdReadOnly}
disabled={submitting}
/>
</div>
</>
) : null;
const apiKeySection = showApiKeyField ? (
<>
<Typography variant="h6" color="blue-gray" className="mt-6 font-semibold">
API-Key
</Typography>
<div className="grid grid-cols-1 gap-4">
<Input
label="API-Key"
required={requireApiKey}
type="password"
value={form.apiKey}
onChange={(event) => {
if (apiKeyReadOnly) return;
setForm((prev) => ({ ...prev, apiKey: event.target.value }));
}}
readOnly={apiKeyReadOnly}
disabled={submitting}
/>
</div>
</>
) : null;
const showEnvHint = Boolean(
status?.superuser?.envProvided ||
serverEnvProvided ||
endpointEnvProvided ||
apiKeyEnvProvided
);
return (
<div className="flex min-h-screen w-full items-center justify-center py-10">
<Card className="w-full max-w-lg border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
className="mb-0 grid place-items-start gap-2 rounded-none bg-transparent p-6"
>
<Typography variant="h4" color="blue-gray">
System Setup
</Typography>
<Typography color="gray" className="font-normal">
Lege einen Superuser sowie mindestens einen Server mit Endpoint und API-Key fest, um StackPulse zu starten.
</Typography>
</CardHeader>
<CardBody className="pt-0">
<form className="mt-4 flex flex-col gap-6" onSubmit={handleSubmit}>
{showEnvHint && (
<Alert color="blue" className="border border-blue-200 bg-blue-50 text-blue-700">
Teile der Konfiguration stammen bereits aus den Umgebungsvariablen.
</Alert>
)}
{superuserSection}
{serverSection}
{endpointSection}
{apiKeySection}
{submitError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{submitError}
</Alert>
)}
{submitSuccess && (
<Alert color="green" className="border border-green-200 bg-green-50 text-green-700">
Setup abgeschlossen. Du wirst zum Login weitergeleitet ...
</Alert>
)}
<Button type="submit" color="blue" disabled={submitting || submitSuccess}>
{submitting ? "Setup läuft ..." : "Setup abschließen"}
</Button>
</form>
</CardBody>
</Card>
</div>
);
}
export default Setup;