Superuser

This commit is contained in:
root
2025-10-24 18:39:32 +00:00
parent 9133928401
commit f0b1ca4b68
19 changed files with 865 additions and 188 deletions
+1
View File
@@ -1,2 +1,3 @@
export * from "@/pages/auth/sign-in";
export * from "@/pages/auth/sign-up";
export * from "@/pages/auth/regsuperuser";
+190
View File
@@ -0,0 +1,190 @@
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;
+9 -62
View File
@@ -13,27 +13,27 @@ export function SignIn() {
<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">Sign In</Typography>
<Typography variant="paragraph" color="blue-gray" className="text-lg font-normal">Enter your email and password to Sign In.</Typography>
<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>
</div>
<form className="mt-8 mb-2 mx-auto w-80 max-w-screen-lg lg:w-1/2">
<div className="mb-1 flex flex-col gap-6">
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
Your email
eMail oder Benutzername
</Typography>
<Input
size="lg"
placeholder="name@mail.com"
placeholder="eMail oder Benutzername"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
/>
<Typography variant="small" color="blue-gray" className="-mb-3 font-medium">
Password
Passwort
</Typography>
<Input
type="password"
type="passwort"
size="lg"
placeholder="********"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
@@ -42,73 +42,20 @@ export function SignIn() {
}}
/>
</div>
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
I agree the&nbsp;
<a
href="#"
className="font-normal text-black transition-colors hover:text-gray-900 underline"
>
Terms and Conditions
</a>
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Button className="mt-6" fullWidth>
Sign In
Anmelden
</Button>
<div className="flex items-center justify-between gap-2 mt-6">
<Checkbox
label={
<Typography
variant="small"
color="gray"
className="flex items-center justify-start font-medium"
>
Subscribe me to newsletter
</Typography>
}
containerProps={{ className: "-ml-2.5" }}
/>
<Typography variant="small" className="font-medium text-gray-900">
<a href="#">
Forgot Password
</a>
</Typography>
</div>
<div className="space-y-4 mt-8">
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_1156_824)">
<path d="M16.3442 8.18429C16.3442 7.64047 16.3001 7.09371 16.206 6.55872H8.66016V9.63937H12.9813C12.802 10.6329 12.2258 11.5119 11.3822 12.0704V14.0693H13.9602C15.4741 12.6759 16.3442 10.6182 16.3442 8.18429Z" fill="#4285F4" />
<path d="M8.65974 16.0006C10.8174 16.0006 12.637 15.2922 13.9627 14.0693L11.3847 12.0704C10.6675 12.5584 9.7415 12.8347 8.66268 12.8347C6.5756 12.8347 4.80598 11.4266 4.17104 9.53357H1.51074V11.5942C2.86882 14.2956 5.63494 16.0006 8.65974 16.0006Z" fill="#34A853" />
<path d="M4.16852 9.53356C3.83341 8.53999 3.83341 7.46411 4.16852 6.47054V4.40991H1.51116C0.376489 6.67043 0.376489 9.33367 1.51116 11.5942L4.16852 9.53356Z" fill="#FBBC04" />
<path d="M8.65974 3.16644C9.80029 3.1488 10.9026 3.57798 11.7286 4.36578L14.0127 2.08174C12.5664 0.72367 10.6469 -0.0229773 8.65974 0.000539111C5.63494 0.000539111 2.86882 1.70548 1.51074 4.40987L4.1681 6.4705C4.8001 4.57449 6.57266 3.16644 8.65974 3.16644Z" fill="#EA4335" />
</g>
<defs>
<clipPath id="clip0_1156_824">
<rect width="16" height="16" fill="white" transform="translate(0.5)" />
</clipPath>
</defs>
</svg>
<span>Sign in With Google</span>
</Button>
<Button size="lg" color="white" className="flex items-center gap-2 justify-center shadow-md" fullWidth>
<img src="/img/twitter-logo.svg" height={24} width={24} alt="" />
<span>Sign in With Twitter</span>
</Button>
</div>
<Typography variant="paragraph" className="text-center text-blue-gray-500 font-medium mt-4">
{/* <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>
</Typography> */}
</form>
</div>
+1 -1
View File
@@ -1,3 +1,3 @@
export * from "@/pages/dashboard/stacks";
export * from "@/pages/dashboard/maintenance";
export * from "@/pages/dashboard/logs";
export * from "@/pages/dashboard/logs";
+134 -1
View File
@@ -123,7 +123,9 @@ export function Maintenance() {
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
testSshConnection,
fetchSuperuserStatus,
removeSuperuserAccount
} = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
@@ -182,6 +184,12 @@ export function Maintenance() {
const scriptIsDirty = scriptConfig ? scriptDraft !== scriptBaseline : false;
const scriptSourceLabel = scriptConfig?.source === "custom" ? "Benutzerdefiniert" : "Standard";
const [superuserStatusLoading, setSuperuserStatusLoading] = useState(true);
const [superuserExists, setSuperuserExists] = useState(false);
const [superuserSummary, setSuperuserSummary] = useState(null);
const [superuserStatusError, setSuperuserStatusError] = useState("");
const [superuserDeleteLoading, setSuperuserDeleteLoading] = useState(false);
const [duplicates, setDuplicates] = useState([]);
const [duplicatesLoading, setDuplicatesLoading] = useState(true);
const [duplicatesError, setDuplicatesError] = useState("");
@@ -197,6 +205,27 @@ export function Maintenance() {
const [portainerUpdatedAt, setPortainerUpdatedAt] = useState(null);
const portainerRequestRef = useRef(null);
const loadSuperuserStatus = useCallback(async ({ silent = false } = {}) => {
setSuperuserStatusLoading(true);
setSuperuserStatusError("");
try {
const data = await fetchSuperuserStatus();
const exists = Boolean(data?.exists);
setSuperuserExists(exists);
setSuperuserSummary(exists ? data.user ?? null : null);
} catch (err) {
const message = err.response?.data?.error || err.message || "Superuser-Status konnte nicht geladen werden";
setSuperuserExists(false);
setSuperuserSummary(null);
setSuperuserStatusError(message);
if (!silent) {
showToast({ variant: "error", title: "Statusabfrage fehlgeschlagen", description: message });
}
} finally {
setSuperuserStatusLoading(false);
}
}, [fetchSuperuserStatus, showToast]);
const fetchPortainerStatus = useCallback(async ({ silent = false } = {}) => {
if (portainerRequestRef.current) {
return portainerRequestRef.current;
@@ -285,6 +314,10 @@ export function Maintenance() {
return requestPromise;
}, [maintenanceActive, updateRunning, showToast]);
useEffect(() => {
loadSuperuserStatus({ silent: true });
}, [loadSuperuserStatus]);
useEffect(() => {
fetchPortainerStatus();
}, [fetchPortainerStatus]);
@@ -423,6 +456,49 @@ export function Maintenance() {
}
}, [deleteSshConfig, showToast]);
const handleSuperuserDelete = useCallback(async () => {
if (superuserDeleteLoading || superuserStatusLoading || !superuserExists) {
return;
}
if (typeof window !== "undefined") {
const confirmed = window.confirm(
"Superuser und zugehörige Gruppe wirklich löschen?\nDies setzt die Superuser-Einrichtung zurück."
);
if (!confirmed) {
return;
}
}
try {
setSuperuserDeleteLoading(true);
const response = await removeSuperuserAccount();
const removedUsers = Number(response?.usersRemoved ?? 0);
showToast({
variant: "success",
title: "Superuser gelöscht",
description: removedUsers > 1
? `${removedUsers} Konten aus der Superuser-Gruppe wurden entfernt.`
: "Superuser-Konto wurde entfernt."
});
await loadSuperuserStatus({ silent: true });
} catch (err) {
const status = err.response?.status;
if (status === 404) {
await loadSuperuserStatus({ silent: true });
showToast({
variant: "info",
title: "Kein Superuser vorhanden",
description: "Es ist kein Superuser mehr vorhanden."
});
} else {
const message = err.response?.data?.error || err.message || "Superuser konnte nicht gelöscht werden";
showToast({ variant: "error", title: "Löschen fehlgeschlagen", description: message });
}
} finally {
setSuperuserDeleteLoading(false);
}
}, [superuserDeleteLoading, superuserStatusLoading, superuserExists, removeSuperuserAccount, loadSuperuserStatus, showToast]);
const handleMaintenanceToggle = useCallback(async (nextActive) => {
if (maintenanceLoading || maintenanceToggleLoading) return;
if (nextActive === maintenanceActive) return;
@@ -633,6 +709,63 @@ export function Maintenance() {
</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>Superuser</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
<div className="space-y-2">
{superuserStatusLoading && (
<p className="text-sm text-stormGrey-500">Status wird geladen</p>
)}
{!superuserStatusLoading && superuserStatusError && (
<p className="text-sm text-sunsetCoral-600">{superuserStatusError}</p>
)}
{!superuserStatusLoading && !superuserStatusError && superuserExists && (
<div className="space-y-1">
<p className="text-sm">Superuser ist angelegt.</p>
<p className="text-xs text-stormGrey-500">
Benutzername: <span className="font-medium text-stormGrey-900">{superuserSummary?.username ?? "unbekannt"}</span>
{superuserSummary?.email ? ` - ${superuserSummary.email}` : ""}
</p>
</div>
)}
{!superuserStatusLoading && !superuserStatusError && !superuserExists && (
<p className="text-sm">Es ist kein Superuser vorhanden.</p>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button
variant="outlined"
color="gray"
onClick={() => loadSuperuserStatus()}
disabled={superuserStatusLoading || superuserDeleteLoading}
className="w-full sm:w-auto"
>
Status aktualisieren
</Button>
<Button
color="red"
onClick={handleSuperuserDelete}
disabled={superuserStatusLoading || superuserDeleteLoading || !superuserExists}
className="w-full sm:w-auto"
>
{superuserDeleteLoading ? "Wird gelöscht…" : "Superuser löschen"}
</Button>
</div>
<p className="text-xs text-stormGrey-500">
Entfernt das Superuser-Konto samt zugehöriger Gruppe. Danach kann der Erstbenutzer bei Bedarf erneut angelegt werden.
</p>
</CardBody>
</Card>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography