Update
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Dashboard, Auth } from "@/layouts";
|
||||
import Setup from "@/pages/setup/setup";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||
<Route path="/auth/*" element={<Auth />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/stacks" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,31 @@ export default function MaintenanceProvider({ children }) {
|
||||
|
||||
const fetchSuperuserStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/auth/superuser/status");
|
||||
return response.data ?? { exists: false, user: null };
|
||||
const data = response.data ?? {};
|
||||
return {
|
||||
exists: Boolean(data.exists),
|
||||
user: data.user ?? null
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSetupStatus = useCallback(async () => {
|
||||
const response = await axios.get("/api/setup/status");
|
||||
return response.data ?? {};
|
||||
}, []);
|
||||
|
||||
const deleteSetupEndpoint = useCallback(async (endpointId) => {
|
||||
const response = await axios.delete(`/api/setup/endpoints/${endpointId}`);
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const deleteSetupServer = useCallback(async (serverId) => {
|
||||
const response = await axios.delete(`/api/setup/servers/${serverId}`);
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const updateSetupApiKey = useCallback(async (serverId, apiKey) => {
|
||||
const response = await axios.put(`/api/setup/servers/${serverId}/api-key`, { apiKey });
|
||||
return response.data ?? { success: false };
|
||||
}, []);
|
||||
|
||||
const removeSuperuserAccount = useCallback(async () => {
|
||||
@@ -159,8 +183,12 @@ export default function MaintenanceProvider({ children }) {
|
||||
deleteSshConfig,
|
||||
testSshConnection,
|
||||
fetchSuperuserStatus,
|
||||
fetchSetupStatus,
|
||||
deleteSetupEndpoint,
|
||||
deleteSetupServer,
|
||||
updateSetupApiKey,
|
||||
removeSuperuserAccount
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, removeSuperuserAccount]);
|
||||
}), [state, fetchConfig, refreshUpdateStatus, setMaintenanceMode, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection, fetchSuperuserStatus, fetchSetupStatus, deleteSetupEndpoint, deleteSetupServer, updateSetupApiKey, removeSuperuserAccount]);
|
||||
|
||||
return (
|
||||
<MaintenanceContext.Provider value={value}>
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Navbar, Footer } from "@/widgets/layout";
|
||||
import routes from "@/routes";
|
||||
import { RegSuperuser } from "@/pages/auth/regsuperuser";
|
||||
|
||||
export function Auth() {
|
||||
const navbarRoutes = [
|
||||
{
|
||||
@@ -36,7 +34,6 @@ export function Auth() {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full">
|
||||
<Routes>
|
||||
<Route path="/regsuperuser" element={<RegSuperuser />} />
|
||||
{routes.map(
|
||||
({ layout, pages }) =>
|
||||
layout === "auth" &&
|
||||
|
||||
@@ -16,53 +16,112 @@ export function Dashboard() {
|
||||
const { sidenavType } = controller;
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [superuserRequired, setSuperuserRequired] = useState(false);
|
||||
const [statusChecked, setStatusChecked] = useState(false);
|
||||
const [setupChecked, setSetupChecked] = useState(false);
|
||||
const [setupIncomplete, setSetupIncomplete] = useState(true);
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
const checkSuperuserStatus = useCallback(async () => {
|
||||
setStatusChecked(false);
|
||||
const checkSetupStatus = useCallback(async () => {
|
||||
setSetupChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/auth/superuser/status");
|
||||
const response = await fetch("/api/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Error("STATUS_REQUEST_FAILED");
|
||||
}
|
||||
const data = await response.json();
|
||||
setSuperuserRequired(!data.exists);
|
||||
setSetupIncomplete(!data.setupComplete);
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Superuser] Statusprüfung fehlgeschlagen:", error);
|
||||
setSuperuserRequired(true);
|
||||
console.error("⚠️ [Setup] Statusprüfung fehlgeschlagen:", error);
|
||||
setSetupIncomplete(true);
|
||||
} finally {
|
||||
setStatusChecked(true);
|
||||
setSetupChecked(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkSuperuserStatus();
|
||||
}, [checkSuperuserStatus]);
|
||||
const checkSession = useCallback(async () => {
|
||||
setAuthChecked(false);
|
||||
try {
|
||||
const response = await fetch("/api/auth/session", { credentials: "include" });
|
||||
if (response.status === 403) {
|
||||
setSetupIncomplete(true);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setIsAuthenticated(Boolean(data?.user));
|
||||
} catch (error) {
|
||||
console.error("⚠️ [Auth] Sessionprüfung fehlgeschlagen:", error);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setAuthChecked(true);
|
||||
}
|
||||
}, [setSetupIncomplete, setIsAuthenticated, setAuthChecked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!statusChecked) return;
|
||||
if (superuserRequired) {
|
||||
if (location.pathname !== "/auth/regsuperuser") {
|
||||
navigate("/auth/regsuperuser", { replace: true });
|
||||
checkSetupStatus();
|
||||
}, [checkSetupStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked || setupIncomplete) return;
|
||||
checkSession();
|
||||
}, [setupChecked, setupIncomplete, checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!setupChecked) return;
|
||||
|
||||
if (setupIncomplete) {
|
||||
if (location.pathname !== "/setup") {
|
||||
navigate("/setup", { replace: true });
|
||||
}
|
||||
} else if (location.pathname === "/auth/regsuperuser") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authChecked) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (location.pathname !== "/auth/sign-in") {
|
||||
navigate("/auth/sign-in", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (location.pathname === "/setup" || location.pathname.startsWith("/auth/")) {
|
||||
navigate("/dashboard/stacks", { replace: true });
|
||||
}
|
||||
}, [superuserRequired, statusChecked, location.pathname, navigate]);
|
||||
}, [setupChecked, setupIncomplete, authChecked, isAuthenticated, location.pathname, navigate]);
|
||||
|
||||
if (!statusChecked) {
|
||||
if (!setupChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Lade Systemstatus ...</span>
|
||||
<span className="text-blue-gray-500">Pruefe Systemkonfiguration ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (superuserRequired) {
|
||||
if (setupIncomplete) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Superuser-Einrichtung erforderlich ...</span>
|
||||
<span className="text-blue-gray-500">Setup erforderlich ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Pruefe Anmeldung ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-blue-gray-50/50">
|
||||
<span className="text-blue-gray-500">Weiterleitung zur Anmeldung ...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
*/
|
||||
import React from "react";
|
||||
import axios from "axios";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
@@ -20,6 +21,8 @@ import ToastProvider from "@/components/ToastProvider.jsx";
|
||||
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
|
||||
import PageProvider from "@/components/PageProvider.jsx";
|
||||
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
export * from "@/pages/auth/regsuperuser";
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -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 & 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { Setup as default, Setup } from "./setup";
|
||||
@@ -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;
|
||||
+16
-2
@@ -3,9 +3,11 @@ import {
|
||||
WrenchScrewdriverIcon,
|
||||
ListBulletIcon,
|
||||
ServerStackIcon,
|
||||
RectangleStackIcon
|
||||
RectangleStackIcon,
|
||||
UserIcon,
|
||||
UserGroupIcon
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Stacks, Maintenance, Logs } from "@/pages/dashboard";
|
||||
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
|
||||
import { SignIn, SignUp } from "@/pages/auth";
|
||||
|
||||
const icon = {
|
||||
@@ -34,6 +36,18 @@ export const routes = [
|
||||
path: "/logs",
|
||||
element: <Logs />,
|
||||
},
|
||||
{
|
||||
icon: <UserIcon {...icon} />,
|
||||
name: "benutzer",
|
||||
path: "/users",
|
||||
element: <Users />,
|
||||
},
|
||||
{
|
||||
icon: <UserGroupIcon {...icon} />,
|
||||
name: "rechtegruppen",
|
||||
path: "/usergroups",
|
||||
element: <Usergroups />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user