Superuser
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from "@/pages/auth/sign-in";
|
||||
export * from "@/pages/auth/sign-up";
|
||||
export * from "@/pages/auth/regsuperuser";
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
<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,3 +1,3 @@
|
||||
export * from "@/pages/dashboard/stacks";
|
||||
export * from "@/pages/dashboard/maintenance";
|
||||
export * from "@/pages/dashboard/logs";
|
||||
export * from "@/pages/dashboard/logs";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user