Forgot Passaord

This commit is contained in:
root
2025-11-05 19:46:57 +00:00
parent 3140a024e6
commit 71ab82b592
31 changed files with 5193 additions and 1259 deletions
+172
View File
@@ -0,0 +1,172 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
const LEVEL_PRIORITY = {
none: 0,
read: 1,
full: 2,
};
const AuthContext = createContext(null);
AuthContext.displayName = "AuthContext";
const normalizePermissions = (raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return Object.entries(raw).reduce((acc, [key, value]) => {
if (typeof key !== "string" || !key) {
return acc;
}
const normalizedValue = typeof value === "string" ? value : String(value ?? "").trim();
acc[key] = normalizedValue || "none";
return acc;
}, {});
};
export function AuthProvider({ children }) {
const [state, setState] = useState({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: false,
});
const setSession = useCallback((payload) => {
const user = payload?.user ?? null;
const permissions = normalizePermissions(payload?.permissions);
setState({
user,
permissions,
loading: false,
error: null,
lastErrorCode: null,
initialized: true,
});
}, []);
const clearSession = useCallback(() => {
setState((prev) => ({
user: null,
permissions: {},
loading: false,
error: null,
lastErrorCode: null,
initialized: prev.initialized || true,
}));
}, []);
const refreshSession = useCallback(async () => {
setState((prev) => ({
...prev,
loading: true,
error: null,
lastErrorCode: null,
}));
try {
const response = await fetch("/api/auth/session", {
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setState({
user: null,
permissions: {},
loading: false,
error: payload?.error ?? null,
lastErrorCode: payload?.error ?? null,
initialized: true,
});
return { ok: false, status: response.status, error: payload?.error ?? null };
}
setSession(payload);
return { ok: true, data: payload };
} catch (err) {
const message = err?.message || "NETWORK_ERROR";
setState({
user: null,
permissions: {},
loading: false,
error: message,
lastErrorCode: "NETWORK_ERROR",
initialized: true,
});
return { ok: false, error: message };
}
}, [setSession]);
const logout = useCallback(async () => {
let capturedError = null;
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const logoutError = new Error(payload?.error || "LOGOUT_FAILED");
logoutError.code = payload?.error || `STATUS_${response.status}`;
throw logoutError;
}
} catch (err) {
capturedError = err;
} finally {
clearSession();
}
if (capturedError) {
throw capturedError;
}
}, [clearSession]);
const hasPermission = useCallback(
(permissionKey, requiredLevel = "full") => {
if (!permissionKey) {
return true;
}
if (state.user?.isSuperuser) {
return true;
}
const current = state.permissions?.[permissionKey] ?? "none";
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
return currentPriority >= requiredPriority;
},
[state.permissions, state.user]
);
const value = useMemo(
() => ({
user: state.user,
permissions: state.permissions,
loading: state.loading,
error: state.error,
lastErrorCode: state.lastErrorCode,
initialized: state.initialized,
isAuthenticated: Boolean(state.user),
refreshSession,
setSession,
clearSession,
logout,
hasPermission,
}),
[state, refreshSession, setSession, clearSession, logout, hasPermission]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
export const AuthContextDisplayName = "/src/components/AuthProvider.jsx";
@@ -0,0 +1,58 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography, Card, CardBody } from "@material-tailwind/react";
import { useAuth } from "@/components/AuthProvider.jsx";
export function PermissionGate({
permission,
requiredLevel = "full",
loadingFallback = null,
children,
}) {
const { initialized, loading, hasPermission } = useAuth();
if (!permission) {
return children;
}
if (!initialized || loading) {
return (
loadingFallback ?? (
<div className="flex min-h-[200px] items-center justify-center">
<Typography variant="small" color="blue-gray">
Berechtigungen werden geladen
</Typography>
</div>
)
);
}
if (hasPermission(permission, requiredLevel)) {
return children;
}
return (
<div className="mt-12 flex items-center justify-center px-4">
<Card className="max-w-xl border border-blue-gray-100 shadow-sm">
<CardBody className="space-y-3 text-center">
<Typography variant="h5" color="blue-gray">
Kein Zugriff auf diesen Bereich
</Typography>
<Typography variant="small" className="text-blue-gray-500">
Für diese Funktion wird die Berechtigung <code>{permission}</code> benötigt.
</Typography>
</CardBody>
</Card>
</div>
);
}
PermissionGate.propTypes = {
permission: PropTypes.string,
requiredLevel: PropTypes.string,
loadingFallback: PropTypes.node,
children: PropTypes.node.isRequired,
};
export default PermissionGate;
+108 -54
View File
@@ -11,7 +11,10 @@ import {
import routes from "@/routes";
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
import { UserGroupDetail } from "@/pages/dashboard/userGroupDetail.jsx";
import { SecurityPhrase } from "@/pages/dashboard/securityPhrase.jsx";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
import { useAuth } from "@/components/AuthProvider.jsx";
import PermissionGate from "@/components/PermissionGate.jsx";
export function Dashboard() {
const [controller, dispatch] = useMaterialTailwindController();
@@ -20,8 +23,13 @@ export function Dashboard() {
const navigate = useNavigate();
const [setupChecked, setSetupChecked] = useState(false);
const [setupIncomplete, setSetupIncomplete] = useState(true);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const {
initialized: authInitialized,
loading: authLoading,
isAuthenticated,
refreshSession,
user
} = useAuth();
const checkSetupStatus = useCallback(async () => {
setSetupChecked(false);
@@ -40,37 +48,15 @@ export function Dashboard() {
}
}, []);
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(() => {
checkSetupStatus();
}, [checkSetupStatus]);
useEffect(() => {
if (!setupChecked || setupIncomplete) return;
checkSession();
}, [setupChecked, setupIncomplete, checkSession]);
if (authInitialized || authLoading) return;
refreshSession();
}, [setupChecked, setupIncomplete, authInitialized, authLoading, refreshSession]);
useEffect(() => {
if (!setupChecked) return;
@@ -82,7 +68,7 @@ export function Dashboard() {
return;
}
if (!authChecked) return;
if (!authInitialized) return;
if (!isAuthenticated) {
if (location.pathname !== "/auth/sign-in") {
@@ -91,10 +77,53 @@ export function Dashboard() {
return;
}
if (location.pathname === "/setup" || location.pathname.startsWith("/auth/")) {
const requiresSecurityPhrase = Boolean(user?.requiresSecurityPhraseDownload);
const isSecurityPhrasePath = location.pathname === "/dashboard/security-phrase";
if (requiresSecurityPhrase && !isSecurityPhrasePath) {
navigate("/dashboard/security-phrase", { replace: true });
return;
}
if (!requiresSecurityPhrase && isSecurityPhrasePath) {
navigate("/dashboard/stacks", { replace: true });
}
}, [setupChecked, setupIncomplete, authChecked, isAuthenticated, location.pathname, navigate]);
}, [
setupChecked,
setupIncomplete,
authInitialized,
isAuthenticated,
location.pathname,
navigate,
user?.requiresSecurityPhraseDownload
]);
useEffect(() => {
if (!setupChecked || setupIncomplete) return;
if (!authInitialized || authLoading) return;
if (!isAuthenticated) {
if (location.pathname !== "/auth/sign-in") {
navigate("/auth/sign-in", { replace: true });
}
return;
}
const isAuthPath = location.pathname.startsWith("/auth/");
const isLogoutPath = location.pathname === "/auth/logout";
if (location.pathname === "/setup" || (isAuthPath && !isLogoutPath)) {
navigate("/dashboard/stacks", { replace: true });
}
}, [
setupChecked,
setupIncomplete,
authInitialized,
authLoading,
isAuthenticated,
location.pathname,
navigate
]);
if (!setupChecked) {
return (
@@ -112,7 +141,7 @@ export function Dashboard() {
);
}
if (!authChecked) {
if (!authInitialized || authLoading) {
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>
@@ -128,26 +157,34 @@ export function Dashboard() {
);
}
const isSecurityPhraseRoute = location.pathname === "/dashboard/security-phrase";
return (
<div className="min-h-screen bg-blue-gray-50/50">
<Sidenav
routes={routes}
brandImg={
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
}
/>
<div className="p-4 xl:ml-80">
<DashboardNavbar />
<Configurator />
<IconButton
size="lg"
color="white"
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
ripple={false}
onClick={() => setOpenConfigurator(dispatch, true)}
>
<Cog6ToothIcon className="h-5 w-5" />
</IconButton>
{!isSecurityPhraseRoute && (
<Sidenav
routes={routes}
brandImg={
sidenavType === "dark" ? "/img/logo-ct.png" : "/img/logo-ct-dark.png"
}
/>
)}
<div className={isSecurityPhraseRoute ? "" : "p-4 xl:ml-80"}>
{!isSecurityPhraseRoute && (
<>
<DashboardNavbar />
<Configurator />
<IconButton
size="lg"
color="white"
className="fixed bottom-8 right-8 z-40 rounded-full shadow-blue-gray-900/10 xl:hidden"
ripple={false}
onClick={() => setOpenConfigurator(dispatch, true)}
>
<Cog6ToothIcon className="h-5 w-5" />
</IconButton>
</>
)}
<Routes>
{routes.map(
({ layout, pages }) =>
@@ -156,12 +193,29 @@ export function Dashboard() {
<Route exact path={path} element={element} />
))
)}
<Route path="users/:userId" element={<UserDetails />} />
<Route path="usergroups/:groupId" element={<UserGroupDetail />} />
<Route path="security-phrase" element={<SecurityPhrase />} />
<Route
path="users/:userId"
element={
<PermissionGate permission="users-access" requiredLevel="read">
<UserDetails />
</PermissionGate>
}
/>
<Route
path="usergroups/:groupId"
element={
<PermissionGate permission="user-groups-access" requiredLevel="read">
<UserGroupDetail />
</PermissionGate>
}
/>
</Routes>
<div className="text-blue-gray-600">
<Footer />
</div>
{!isSecurityPhraseRoute && (
<div className="text-blue-gray-600">
<Footer />
</div>
)}
</div>
</div>
);
+14 -11
View File
@@ -20,23 +20,26 @@ import "./tailwind.css";
import ToastProvider from "@/components/ToastProvider.jsx";
import MaintenanceProvider from "@/components/MaintenanceProvider.jsx";
import PageProvider from "@/components/PageProvider.jsx";
import { AuthProvider } from "@/components/AuthProvider.jsx";
axios.defaults.withCredentials = true;
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<MaintenanceProvider>
<ToastProvider>
<PageProvider>
<ThemeProvider>
<MaterialTailwindControllerProvider>
<App />
</MaterialTailwindControllerProvider>
</ThemeProvider>
</PageProvider>
</ToastProvider>
</MaintenanceProvider>
<AuthProvider>
<MaintenanceProvider>
<ToastProvider>
<PageProvider>
<ThemeProvider>
<MaterialTailwindControllerProvider>
<App />
</MaterialTailwindControllerProvider>
</ThemeProvider>
</PageProvider>
</ToastProvider>
</MaintenanceProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);
+400
View File
@@ -0,0 +1,400 @@
import React, { useCallback, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
CardBody,
CardHeader,
Typography,
Input,
Button,
Spinner,
Alert
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
const PHRASE_WORD_COUNT = 8;
const normalizeWord = (value) => String(value || "").trim().toLowerCase();
const extractWordsFromText = (text = "") => {
const normalized = String(text || "").replace(/[\r\n\t]+/g, " ").trim().toLowerCase();
if (!normalized) {
return [];
}
return normalized.split(/\s+/).filter(Boolean);
};
const buildPhrasePayload = (words = []) => {
const normalized = words.map(normalizeWord).filter(Boolean);
return {
words: normalized,
canonical: normalized.join("")
};
};
export function ForgotPassword() {
const navigate = useNavigate();
const { showToast } = useToast();
const [username, setUsername] = useState("");
const [phraseInputs, setPhraseInputs] = useState(Array(PHRASE_WORD_COUNT).fill(""));
const [fileName, setFileName] = useState("");
const [fileContent, setFileContent] = useState("");
const [verificationError, setVerificationError] = useState("");
const [verifying, setVerifying] = useState(false);
const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState("");
const phraseWords = useMemo(() => {
if (fileContent.trim()) {
return extractWordsFromText(fileContent);
}
return phraseInputs.map(normalizeWord).filter(Boolean);
}, [fileContent, phraseInputs]);
const handlePhraseInputChange = useCallback((index, value) => {
setPhraseInputs((prev) => {
const next = [...prev];
next[index] = value;
return next;
});
}, []);
const handleFileUpload = useCallback((event) => {
const [file] = event.target.files || [];
if (!file) {
setFileName("");
setFileContent("");
return;
}
setFileName(file.name);
const reader = new FileReader();
reader.onload = (loadEvent) => {
const text = String(loadEvent.target?.result || "");
setFileContent(text);
setPhraseInputs(Array(PHRASE_WORD_COUNT).fill(""));
};
reader.onerror = () => {
setFileName("");
setFileContent("");
showToast({
variant: "error",
title: "Datei konnte nicht gelesen werden",
description: "Bitte lade die Datei erneut hoch."
});
};
reader.readAsText(file);
}, [showToast]);
const handleVerifyPhrase = useCallback(async () => {
setVerificationError("");
setVerificationToken("");
const trimmedUsername = username.trim();
if (!trimmedUsername) {
setVerificationError("Bitte gib deinen Benutzernamen ein.");
return;
}
const words = phraseWords;
const hasFile = Boolean(fileContent.trim());
if (!hasFile && words.length !== PHRASE_WORD_COUNT) {
setVerificationError("Bitte gib alle acht Wörter des Sicherheitsschlüssels ein.");
return;
}
if (hasFile && words.length !== PHRASE_WORD_COUNT) {
setVerificationError("Die hochgeladene Datei enthält nicht genau acht Wörter.");
return;
}
const payload = buildPhrasePayload(words);
if (!payload.canonical) {
setVerificationError("Der Sicherheitsschlüssel ist ungültig oder leer.");
return;
}
setVerifying(true);
try {
const response = await axios.post("/api/auth/recover/verify", {
username: trimmedUsername,
phrase: payload.canonical,
words: payload.words
});
const responseToken = response.data?.token;
if (!responseToken) {
throw new Error("TOKEN_MISSING");
}
setVerificationToken(responseToken);
showToast({
variant: "success",
title: "Sicherheitsschlüssel bestätigt",
description: "Du kannst jetzt ein neues Passwort festlegen."
});
} catch (error) {
const serverError = error.response?.data?.error;
if (serverError === "USER_NOT_FOUND") {
setVerificationError("Der angegebene Benutzer wurde nicht gefunden.");
} else if (serverError === "PHRASE_MISMATCH") {
setVerificationError("Der Sicherheitsschlüssel passt nicht zum Benutzer.");
} else if (serverError === "PHRASE_NOT_INITIALIZED") {
setVerificationError("Für diesen Benutzer ist aktuell kein Sicherheitsschlüssel hinterlegt.");
} else if (serverError === "PHRASE_REQUIRED") {
setVerificationError("Bitte gib den Sicherheitsschlüssel ein.");
} else if (serverError === "USERNAME_REQUIRED") {
setVerificationError("Der Benutzername darf nicht leer sein.");
} else if (serverError === "INVALID_PASSWORD") {
setVerificationError("Der Sicherheitsschlüssel ist ungültig.");
} else {
setVerificationError("Die Sicherheitsprüfung ist fehlgeschlagen. Bitte versuche es erneut.");
}
} finally {
setVerifying(false);
}
}, [fileContent, phraseWords, showToast, username]);
const handleResetPassword = useCallback(async () => {
setResetError("");
if (!verificationToken) {
setResetError("Bitte bestätige zuerst deinen Sicherheitsschlüssel.");
return;
}
const trimmedPassword = password.trim();
const trimmedConfirm = confirmPassword.trim();
if (!trimmedPassword || !trimmedConfirm) {
setResetError("Bitte gib das neue Passwort zweimal ein.");
return;
}
if (trimmedPassword !== trimmedConfirm) {
setResetError("Die Passwörter stimmen nicht überein.");
return;
}
setResetting(true);
try {
await axios.post("/api/auth/recover/reset", {
token: verificationToken,
password: trimmedPassword,
confirmPassword: trimmedConfirm
});
showToast({
variant: "success",
title: "Passwort aktualisiert",
description: "Du kannst dich jetzt mit deinem neuen Passwort anmelden."
});
navigate("/auth/sign-in", { replace: true });
} catch (error) {
const serverError = error.response?.data?.error;
if (serverError === "TOKEN_INVALID_OR_EXPIRED") {
setResetError("Die Wiederherstellungssitzung ist abgelaufen. Bitte starte den Vorgang erneut.");
setVerificationToken("");
} else if (serverError === "PASSWORD_TOO_SHORT") {
setResetError("Das Passwort muss mindestens 8 Zeichen enthalten.");
} else if (serverError === "PASSWORD_REQUIRED") {
setResetError("Bitte gib ein neues Passwort ein.");
} else if (serverError === "PASSWORD_MISMATCH") {
setResetError("Die Passwörter stimmen nicht überein.");
} else {
setResetError("Das Passwort konnte nicht zurückgesetzt werden. Bitte versuche es erneut.");
}
} finally {
setResetting(false);
}
}, [confirmPassword, navigate, password, showToast, verificationToken]);
const verificationCompleted = Boolean(verificationToken);
return (
<section className="m-8 flex justify-center">
<Card className="w-full max-w-3xl">
<CardHeader floated={false} shadow={false} className="rounded-none border-b border-blue-gray-50 bg-white px-6 py-5">
<Typography variant="h4" color="blue-gray" className="font-semibold">
Passwort zurücksetzen
</Typography>
<Typography color="blue-gray" className="mt-1 text-sm">
Gib deinen Benutzernamen und den Sicherheitsschlüssel (8 Wörter) ein oder lade die gespeicherte Schlüsseldatei.
</Typography>
</CardHeader>
<CardBody className="space-y-8">
<div className="space-y-4">
<Typography variant="h6" color="blue-gray">
Schritt 1: Sicherheitsschlüssel prüfen
</Typography>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Benutzername
</Typography>
<Input
size="lg"
placeholder="Benutzername"
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={verificationCompleted || verifying}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Sicherheitsschlüssel eingeben
</Typography>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4 min-w-[20px] [&>div]:!min-w-0">
{phraseInputs.map((value, index) => (
<Input
key={`phrase-input-${index}`}
placeholder={`Wort ${index + 1}`}
value={value}
onChange={(event) => handlePhraseInputChange(index, event.target.value)}
disabled={verificationCompleted || verifying || Boolean(fileContent.trim())}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
))}
</div>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Alternativ: Schlüsseldatei (.txt) hochladen
</Typography>
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<input
type="file"
accept=".txt"
onChange={handleFileUpload}
disabled={verificationCompleted || verifying}
className="block w-full max-w-xs text-sm text-blue-gray-500 file:mr-4 file:rounded-md file:border-0 file:bg-blue-gray-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-blue-gray-700 hover:file:bg-blue-gray-100"
/>
{fileName && (
<Typography className="text-xs text-blue-gray-500">
Ausgewählte Datei: <span className="font-medium text-blue-gray-700">{fileName}</span>
</Typography>
)}
</div>
</div>
{verificationError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{verificationError}
</Alert>
)}
<div className="flex flex-wrap items-center gap-4">
<Button
color="blue"
className="normal-case"
onClick={handleVerifyPhrase}
disabled={verifying || verificationCompleted}
>
{verifying ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" /> Prüfe Sicherheitsschlüssel ...
</span>
) : (
"Sicherheitsschlüssel prüfen"
)}
</Button>
{verificationCompleted && (
<Typography className="text-sm text-blue-gray-500">
Die Wiederherstellung bleibt für kurze Zeit aktiv. Bitte setze dein Passwort zeitnah zurück.
</Typography>
)}
</div>
</div>
{/* Schritt 2 */}
<div className="space-y-4">
<Typography variant="h6" color={verificationCompleted ? "blue-gray" : "gray"}>
Schritt 2: Neues Passwort festlegen
</Typography>
{!verificationCompleted && (
<Typography className="text-sm text-blue-gray-500">
Nach erfolgreicher Prüfung des Sicherheitsschlüssels kannst du hier ein neues Passwort vergeben.
</Typography>
)}
{verificationCompleted && (
<div className="space-y-4">
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Neues Passwort
</Typography>
<Input
type="password"
size="lg"
placeholder="Neues Passwort"
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={resetting}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Passwort bestätigen
</Typography>
<Input
type="password"
size="lg"
placeholder="Passwort bestätigen"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
disabled={resetting}
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{ className: "before:content-none after:content-none" }}
/>
</div>
{resetError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{resetError}
</Alert>
)}
<Button
color="green"
className="normal-case"
onClick={handleResetPassword}
disabled={resetting}
>
{resetting ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4" /> Speichere Passwort ...
</span>
) : (
"Neues Passwort speichern"
)}
</Button>
</div>
)}
</div>
<div className="border-t border-blue-gray-50 pt-4">
<Typography
as="button"
type="button"
onClick={() => navigate("/auth/sign-in")}
className="text-sm font-medium text-blue-600 transition hover:text-blue-800"
>
Zurück zur Anmeldung
</Typography>
</div>
</CardBody>
</Card>
</section>
);
}
export default ForgotPassword;
+2
View File
@@ -1,2 +1,4 @@
export * from "@/pages/auth/sign-in";
export * from "@/pages/auth/sign-up";
export * from "@/pages/auth/sign-out";
export * from "@/pages/auth/forgot-password";
+31 -4
View File
@@ -7,9 +7,11 @@ import {
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import pattern from "@/assets/images/pattern.png";
import { useAuth } from "@/components/AuthProvider.jsx";
export function SignIn() {
const navigate = useNavigate();
const { setSession, refreshSession } = useAuth();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
@@ -42,8 +44,12 @@ export function SignIn() {
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
if (sessionData?.user) {
setSession(sessionData);
}
if (sessionData?.user && isActive) {
navigate("/dashboard/stacks", { replace: true });
const requiresSecurityPhrase = Boolean(sessionData.user.requiresSecurityPhraseDownload);
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
return;
}
}
@@ -64,7 +70,7 @@ export function SignIn() {
isActive = false;
controller.abort();
};
}, [navigate]);
}, [navigate, setSession]);
const handleSubmit = useCallback(
async (event) => {
@@ -108,7 +114,20 @@ export function SignIn() {
return;
}
navigate("/dashboard/stacks", { replace: true });
let sessionPayload = null;
if (payload?.user) {
setSession(payload);
sessionPayload = payload;
} else {
const refreshed = await refreshSession();
if (refreshed?.ok && refreshed?.data?.user) {
sessionPayload = refreshed.data;
}
}
const requiresSecurityPhrase = Boolean(sessionPayload?.user?.requiresSecurityPhraseDownload);
navigate(requiresSecurityPhrase ? "/dashboard/security-phrase" : "/dashboard/stacks", { replace: true });
} catch (err) {
console.error("⚠️ [Auth] Anmeldung fehlgeschlagen:", err);
setError("Netzwerkfehler bitte erneut versuchen.");
@@ -116,7 +135,7 @@ export function SignIn() {
setLoading(false);
}
},
[identifier, password, loading, navigate]
[identifier, password, loading, navigate, refreshSession, setSession]
);
if (!statusChecked) {
@@ -190,6 +209,14 @@ export function SignIn() {
<Button type="submit" className="mt-6" fullWidth disabled={loading}>
{loading ? "Anmeldung läuft ..." : "Anmelden"}
</Button>
<Typography
as="button"
type="button"
onClick={() => navigate("/auth/forgot-password")}
className="mt-4 text-sm font-medium text-blue-600 transition hover:text-blue-800"
>
Passwort vergessen?
</Typography>
</form>
</div>
<div className="w-2/5 h-full hidden lg:block">
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardBody, Typography, Spinner } from "@material-tailwind/react";
import { useAuth } from "@/components/AuthProvider.jsx";
export function SignOut() {
const navigate = useNavigate();
const { logout } = useAuth();
const [status, setStatus] = useState("abmelden...");
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
const performLogout = async () => {
try {
setStatus("Abmeldung läuft …");
setError(null);
await logout();
} catch (err) {
if (!cancelled) {
console.error("⚠️ [Auth] Logout fehlgeschlagen:", err);
setError("Abmeldung fehlgeschlagen. Bitte versuche es erneut.");
}
} finally {
if (!cancelled) {
navigate("/auth/sign-in", { replace: true });
}
}
};
performLogout();
return () => {
cancelled = true;
};
}, [logout, navigate]);
return (
<section className="m-8 flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md border border-blue-gray-100 shadow-sm">
<CardBody className="space-y-4 text-center">
<div className="flex items-center justify-center gap-3">
<Spinner className="h-5 w-5 text-blue-gray-500" />
<Typography color="blue-gray" className="text-sm font-medium">
{status}
</Typography>
</div>
{error && (
<Typography color="red" className="text-sm">
{error}
</Typography>
)}
<Typography className="text-xs text-blue-gray-400">
Du wirst automatisch weitergeleitet
</Typography>
</CardBody>
</Card>
</section>
);
}
SignOut.displayName = "/src/pages/auth/sign-out.jsx";
+1
View File
@@ -5,3 +5,4 @@ export * from "@/pages/dashboard/users";
export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
export * from "@/pages/dashboard/userGroupDetail";
export * from "@/pages/dashboard/securityPhrase";
+52 -26
View File
@@ -13,6 +13,7 @@ import {
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const UPDATE_STAGE_LABELS = {
initializing: "Vorbereitung",
@@ -176,6 +177,9 @@ export function Logs() {
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const { hasPermission } = useAuth();
const canDeleteLogs = hasPermission("logs-delete", "full");
const canExportLogs = hasPermission("logs-export", "full");
const {
@@ -607,6 +611,10 @@ export function Logs() {
]);
const handleDeleteLog = async (id) => {
if (!canDeleteLogs) {
setError("Dir fehlt die Berechtigung, einzelne Logeinträge zu löschen.");
return;
}
if (!window.confirm("Diesen Log-Eintrag dauerhaft löschen?")) return;
setActionLoading(true);
setError("");
@@ -622,6 +630,10 @@ export function Logs() {
};
const handleDeleteFiltered = async () => {
if (!canDeleteLogs) {
setError("Dir fehlt die Berechtigung, Logs zu löschen.");
return;
}
if (!logs.length) return;
if (!window.confirm("Alle angezeigten Logs (entsprechend Filter) löschen?")) return;
setActionLoading(true);
@@ -642,6 +654,10 @@ export function Logs() {
};
const handleExport = async (format) => {
if (!canExportLogs) {
setError("Dir fehlt die Berechtigung, Logs zu exportieren.");
return;
}
setActionLoading(true);
setError("");
try {
@@ -716,23 +732,31 @@ export function Logs() {
{filtersOpen && (
<div className="mb-8">
<div className="flex flex-wrap gap-2">
<Button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
className="w-full md:flex-1">
Export TXT</Button>
<Button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
className="w-full md:flex-1">
Export SQL</Button>
<Button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
Angezeigte löschen</Button>
</div>
{(canExportLogs || canDeleteLogs) && (
<div className="flex flex-wrap gap-2">
{canExportLogs && (
<>
<Button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
className="w-full md:flex-1">
Export TXT</Button>
<Button
onClick={() => handleExport('sql')}
disabled={actionLoading || loading}
className="w-full md:flex-1">
Export SQL</Button>
</>
)}
{canDeleteLogs && (
<Button
onClick={handleDeleteFiltered}
disabled={actionLoading || loading || logs.length === 0}
className="w-full md:flex-1 bg-sunsetCoral-500 hover:bg-sunsetCoral-600">
Angezeigte löschen</Button>
)}
</div>
)}
<div className="grid gap-6 mt-10">
<div className="grid gap-4 md:grid-cols-2">
@@ -1270,15 +1294,17 @@ export function Logs() {
</div>
</td>
<td className="px-6 py-4 align-top">
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
>
Löschen
</button>
</Typography>
{canDeleteLogs && (
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60"
>
Löschen
</button>
</Typography>
)}
</td>
</tr>
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,240 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import {
Card,
CardBody,
Typography,
Button,
Spinner,
Alert
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { ArrowDownTrayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { useAuth } from "@/components/AuthProvider.jsx";
const formatWordsAsText = (words = []) => {
if (!Array.isArray(words) || words.length === 0) {
return "";
}
const rows = [];
for (let index = 0; index < words.length; index += 4) {
rows.push(words.slice(index, index + 4).join(" "));
}
return rows.join("\n");
};
const groupWords = (words = []) => {
if (!Array.isArray(words)) {
return [];
}
const rows = [];
for (let index = 0; index < words.length; index += 4) {
rows.push(words.slice(index, index + 4));
}
return rows;
};
export function SecurityPhrase() {
const navigate = useNavigate();
const { user, setSession, refreshSession } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [words, setWords] = useState([]);
const [saving, setSaving] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const requiresDownload = Boolean(user?.requiresSecurityPhraseDownload);
const groupedWords = useMemo(() => groupWords(words), [words]);
const triggerDownload = useCallback((content) => {
if (!content) {
return;
}
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const baseName = user?.username ? `${user.username}-sicherheitsschluessel` : "security-phrase";
link.download = `${baseName}.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [user?.username]);
const fetchPhrase = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await axios.get("/api/users/me/security-phrase");
const phraseWords = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
if (!phraseWords.length) {
throw new Error("EMPTY_SECURITY_PHRASE");
}
setWords(phraseWords);
setDownloaded(false);
} catch (err) {
if (err?.response?.status === 409 || err?.response?.data?.error === "SECURITY_PHRASE_ALREADY_DOWNLOADED") {
await refreshSession();
navigate("/dashboard/stacks", { replace: true });
return;
}
const message = err?.response?.data?.error || err?.message || "Sicherheitsschlüssel konnte nicht geladen werden.";
setError(message);
} finally {
setLoading(false);
}
}, [navigate, refreshSession]);
useEffect(() => {
if (!user) {
navigate("/auth/sign-in", { replace: true });
return;
}
if (!requiresDownload) {
navigate("/dashboard/stacks", { replace: true });
return;
}
fetchPhrase();
}, [user, requiresDownload, fetchPhrase, navigate]);
const handleRetry = useCallback(() => {
fetchPhrase();
}, [fetchPhrase]);
const handleDownload = useCallback(async () => {
if (saving || !Array.isArray(words) || words.length === 0) {
return;
}
setSaving(true);
setError("");
try {
const text = formatWordsAsText(words);
triggerDownload(text);
const response = await axios.post("/api/users/me/security-phrase/downloaded");
const downloadedAt = response.data?.item?.downloadedAt || null;
const sessionPayload = response.data?.session;
if (sessionPayload?.user) {
setSession(sessionPayload);
} else {
await refreshSession();
}
setDownloaded(Boolean(downloadedAt));
navigate("/dashboard/stacks", { replace: true });
} catch (err) {
const message = err?.response?.data?.error || err?.message || "Download konnte nicht bestätigt werden.";
setError(message);
} finally {
setSaving(false);
}
}, [words, saving, triggerDownload, refreshSession, navigate, setSession]);
if (!requiresDownload) {
return null;
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-blue-gray-50/50 px-4 py-10">
<div className="mb-6 text-center">
<Typography variant="h2" className="font-semibold text-blue-gray-800">
Sicherheitsschlüssel sichern
</Typography>
<Typography color="blue-gray" className="mt-2 max-w-3xl text-base">
Bitte lade den einmaligen Sicherheitsschlüssel herunter und bewahre ihn sicher auf. Er wird benötigt, um dein Passwort zurückzusetzen.
Ohne den Download kannst du die Anwendung nicht weiter nutzen.
</Typography>
</div>
<Card className="w-full max-w-3xl shadow-lg">
<CardBody className="space-y-6">
{loading && (
<div className="flex items-center justify-center py-10">
<Spinner className="h-10 w-10 text-blue-gray-500" />
</div>
)}
{!loading && error && (
<Alert
color="red"
className="border border-red-200 bg-red-50 text-red-700"
icon={<ArrowPathIcon className="h-5 w-5" />}
>
<div className="flex items-start justify-between gap-4">
<div>
<Typography variant="h6" color="red">
Fehler
</Typography>
<Typography color="red" className="text-sm">
{error}
</Typography>
</div>
<Button
size="sm"
color="red"
variant="text"
onClick={handleRetry}
className="normal-case"
>
Erneut versuchen
</Button>
</div>
</Alert>
)}
{!loading && !error && (
<>
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-6">
<Typography variant="h5" className="mb-4 text-blue-gray-800">
Deine Sicherheitsphrase
</Typography>
<div className="space-y-3">
{groupedWords.map((row, rowIndex) => (
<div
key={`phrase-row-${rowIndex}`}
className="flex flex-wrap items-center justify-center gap-4 text-lg font-mono tracking-wide text-blue-gray-900 md:text-xl"
>
{row.map((word, wordIndex) => (
<span key={`phrase-${rowIndex}-${wordIndex}`} className="uppercase">
{word}
</span>
))}
</div>
))}
</div>
</div>
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<div className="text-sm text-blue-gray-600">
Der Download startet im TXT-Format. Bewahre die Datei sicher auf sie kann später nicht erneut angezeigt werden.
</div>
<Button
color="blue"
size="md"
className="flex items-center gap-2 normal-case"
onClick={handleDownload}
disabled={saving || downloaded || words.length === 0}
>
<ArrowDownTrayIcon className="h-5 w-5" />
Sicherheitsschlüssel herunterladen
</Button>
</div>
</>
)}
</CardBody>
</Card>
</div>
);
}
export default SecurityPhrase;
+66 -28
View File
@@ -4,6 +4,7 @@ import { io } from "socket.io-client";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
import {
Typography,
@@ -73,6 +74,10 @@ export function Stacks() {
script: scriptConfig,
ssh: sshConfig,
} = useMaintenance();
const { hasPermission } = useAuth();
const canRedeploySingle = hasPermission("stacks-redeploy-single", "full");
const canRedeploySelection = hasPermission("stacks-redeploy-selection", "full");
const canRedeployAll = hasPermission("stacks-redeploy-all", "full");
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
@@ -519,6 +524,7 @@ export function Stacks() {
}, [filteredStacks.length, perPage, page, setPage]);
const toggleStackSelection = (stackId, disabled) => {
if (!canRedeploySelection) return;
if (disabled) return;
setSelectedStackIds(prev =>
prev.includes(stackId)
@@ -527,8 +533,20 @@ export function Stacks() {
);
};
useEffect(() => {
if (!canRedeploySelection) {
if (selectedStackIds.length) {
setSelectedStackIds([]);
}
if (selectionPromptVisible) {
setSelectionPromptVisible(false);
}
}
}, [canRedeploySelection, selectedStackIds.length, selectionPromptVisible]);
const applySelectionPreference = (action) => {
if (!canRedeploySelection) return;
const remember = rememberSelectionChoice;
if (typeof window !== 'undefined') {
try {
@@ -559,6 +577,7 @@ export function Stacks() {
};
const clearStoredSelectionPreference = () => {
if (!canRedeploySelection) return;
if (typeof window !== 'undefined') {
try {
window.sessionStorage.removeItem(SELECTION_PROMPT_STORAGE_KEY);
@@ -577,15 +596,20 @@ export function Stacks() {
};
const clearSelection = () => {
if (!canRedeploySelection) return;
setSelectedStackIds([]);
setSelectionPromptVisible(false);
};
const handleChipRemove = (stackId) => {
if (!canRedeploySelection) return;
setSelectedStackIds((prev) => prev.filter((id) => id !== stackId));
};
const handleRedeploy = async (stackId) => {
if (!canRedeploySingle) {
return;
}
const snapshot = stacksByIdRef.current.get(stackId);
const phase = snapshot?.redeployPhase;
if (phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED) return;
@@ -636,6 +660,9 @@ export function Stacks() {
};
const handleRedeployAll = async () => {
if (!canRedeployAll) {
return;
}
if (!eligibleFilteredStacks.length) return;
const targetIdList = eligibleFilteredStacks.map((stack) => stack.Id);
@@ -687,6 +714,9 @@ export function Stacks() {
};
const handleRedeploySelection = async () => {
if (!canRedeploySelection) {
return;
}
if (!selectedStackIds.length) return;
const eligibleIds = selectedStackIds.filter((id) => {
@@ -747,22 +777,25 @@ export function Stacks() {
}
};
const hasSelection = selectedStackIds.length > 0;
const hasSelection = canRedeploySelection && selectedStackIds.length > 0;
const hasOutdatedStacks = eligibleFilteredStacks.length > 0;
const bulkButtonLabel = hasSelection
const showBulkButton = (canRedeploySelection && hasSelection) || canRedeployAll;
const bulkButtonLabel = canRedeploySelection && hasSelection
? `Redeploy Auswahl (${selectedStackIds.length})`
: 'Redeploy Alle';
const bulkActionDisabled = maintenanceLocked || (hasSelection
? selectionPromptVisible || selectedStackIds.length === 0 || selectedStackIds.every(id => {
const targetStack = stacks.find(stack => stack.Id === id);
if (!targetStack) return true;
if (targetStack.updateStatus === '✅') return true;
if (targetStack.redeployDisabled) return true;
const phase = targetStack.redeployPhase;
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
})
: !hasOutdatedStacks);
const bulkActionDisabled = maintenanceLocked || (
canRedeploySelection && hasSelection
? selectionPromptVisible || selectedStackIds.every(id => {
const targetStack = stacks.find(stack => stack.Id === id);
if (!targetStack) return true;
if (targetStack.updateStatus === '✅') return true;
if (targetStack.redeployDisabled) return true;
const phase = targetStack.redeployPhase;
return phase === REDEPLOY_PHASES.STARTED || phase === REDEPLOY_PHASES.QUEUED;
})
: (!canRedeployAll || !hasOutdatedStacks)
);
const handleBulkRedeploy = () => {
if (maintenanceLocked) {
@@ -774,9 +807,9 @@ export function Stacks() {
return;
}
if (hasSelection) {
if (canRedeploySelection && hasSelection) {
handleRedeploySelection();
} else {
} else if (canRedeployAll) {
handleRedeployAll();
}
};
@@ -868,7 +901,7 @@ export function Stacks() {
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-5">
{selectionPreferenceStored && (
{canRedeploySelection && selectionPreferenceStored && (
<button
onClick={clearStoredSelectionPreference}
className="block antialiased font-sans text-sm leading-normal text-inherit text-xs font-medium text-warmAmberGlow-500 underline underline-offset-2 transition hover:text-warmAmberGlow-600"
@@ -881,7 +914,7 @@ export function Stacks() {
</div>
)}
{selectionPromptVisible && (
{canRedeploySelection && selectionPromptVisible && (
<div className="flex flex-col mt-5">
<div className="flex flex-col gap-3 rounded-lg border border-arcticBlue-800 bg-arcticBlue-900/90 px-4 py-3 text-arcticBlue-100 md:flex-row md:items-center md:justify-between">
@@ -921,8 +954,9 @@ export function Stacks() {
</div>
)}
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
{selectedStackIds.length > 0 && (
{showBulkButton && (
<div id="collect" className="mt-8 flex flex-col gap-4 md:flex-row md:items-start md:gap-6">
{canRedeploySelection && selectedStackIds.length > 0 && (
<div className="w-full md:order-first order-last md:w-3/4">
<div className="flex flex-col gap-3 text-sm text-gray-300">
<div className="flex flex-wrap items-center gap-2">
@@ -963,7 +997,8 @@ export function Stacks() {
</Button>
</div>
</div>
</div>
)}
</CardBody>
</Card>
@@ -978,6 +1013,7 @@ export function Stacks() {
const isCurrent = stack.updateStatus === '✅';
const isSelfStack = Boolean(stack.redeployDisabled);
const isSelectable = !isBusy && !isCurrent && !isSelfStack;
const canSelectStack = canRedeploySelection && isSelectable;
return (
<Card key={stack.Id}>
@@ -989,13 +1025,15 @@ export function Stacks() {
${!isSelectable || isBusy ? 'opacity-75 bg-stormGrey-200/20' : ''}`}
>
<div className="flex items-center space-x-4" id="left">
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStackSelection(stack.Id, !isSelectable)}
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!isSelectable ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!isSelectable}
/>
{canRedeploySelection && (
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStackSelection(stack.Id, !canSelectStack)}
className={`h-5 w-5 text-purple-500 focus:ring-purple-400 border-gray-600 bg-gray-900 rounded ${!canSelectStack ? 'opacity-40 cursor-not-allowed' : ''}`}
disabled={!canSelectStack}
/>
)}
<div className={`w-12 h-12 flex items-center justify-center rounded-full
${stack.updateStatus === '✅' ? 'bg-mossGreen-600' :
stack.updateStatus === '⚠️' ? 'bg-warmAmberGlow-400' :
@@ -1031,7 +1069,7 @@ export function Stacks() {
<span className="antialiased font-sans font-light leading-normal text-xs uppercase tracking-wide text-stormGrey-500">Status</span>
<span className="text-mossGreen-600">Aktuell</span>
</>
) : (
) : (canRedeploySingle ? (
<Button
onClick={() => handleRedeploy(stack.Id)}
disabled={isBusy}
@@ -1039,7 +1077,7 @@ export function Stacks() {
>
Redeploy
</Button>
)}
) : null)}
</div>
</CardBody>
+259 -16
View File
@@ -17,6 +17,7 @@ import { useToast } from "@/components/ToastProvider.jsx";
import { platformSettingsData } from "@/data";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const _ = AVATAR_COLORS.join(" ");
@@ -55,7 +56,8 @@ const mapUser = (item) => ({
lastLogin: item?.lastLogin || null,
createdAt: item?.createdAt || null,
updatedAt: item?.updatedAt || null,
groups: normalizeUserGroups(item?.groups)
groups: normalizeUserGroups(item?.groups),
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
});
const extractPrimaryGroupId = (user) => {
@@ -94,6 +96,7 @@ export function UserDetails() {
const { userId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [user, setUser] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
@@ -106,8 +109,23 @@ export function UserDetails() {
const [groupsError, setGroupsError] = useState("");
const [savingUser, setSavingUser] = useState(false);
const [saveError, setSaveError] = useState("");
const [securityPhraseWords, setSecurityPhraseWords] = useState([]);
const [securityPhraseDownloadedAt, setSecurityPhraseDownloadedAt] = useState(null);
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
const [securityPhraseError, setSecurityPhraseError] = useState("");
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
const maintenanceActive = Boolean(maintenance?.active);
const canEditUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "full"));
const canReadUsers = Boolean(authUser?.isSuperuser || hasPermission("users-edit", "read"));
const canViewSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "read"));
const canRenewSecurityPhrase = Boolean(authUser?.isSuperuser || hasPermission("users-security-phrase", "full"));
if (!canReadUsers) {
return null;
}
const isSuperuserUser = useMemo(() => {
if (!Array.isArray(user?.groups)) {
return false;
@@ -140,6 +158,9 @@ export function UserDetails() {
throw new Error("USER_NOT_FOUND");
}
setUser(item);
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
setSecurityPhraseError("");
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
@@ -255,30 +276,34 @@ export function UserDetails() {
);
const handleUsernameChange = useCallback((event) => {
if (!canEditUsers) return;
const { value } = event.target;
setFormValues((prev) => ({
...prev,
username: value
}));
}, []);
}, [canEditUsers]);
const handleEmailChange = useCallback((event) => {
if (!canEditUsers) return;
const { value } = event.target;
setFormValues((prev) => ({
...prev,
email: value
}));
}, []);
}, [canEditUsers]);
const handlePasswordChange = useCallback((event) => {
if (!canEditUsers) return;
const { value } = event.target;
setFormValues((prev) => ({
...prev,
password: value
}));
}, []);
}, [canEditUsers]);
const handleGroupChange = useCallback((value) => {
if (!canEditUsers) return;
if (!value) {
setFormValues((prev) => ({
...prev,
@@ -291,17 +316,18 @@ export function UserDetails() {
...prev,
groupId: Number.isFinite(numeric) && numeric > 0 ? numeric : null
}));
}, []);
}, [canEditUsers]);
const handleAvatarColorChange = useCallback((value) => {
if (!canEditUsers) return;
setFormValues((prev) => ({
...prev,
avatarColor: value || ""
}));
}, []);
}, [canEditUsers]);
const handleSaveUser = useCallback(async () => {
if (!user || !hasChanges) {
if (!canEditUsers || !user || !hasChanges) {
return;
}
@@ -364,7 +390,89 @@ export function UserDetails() {
} finally {
setSavingUser(false);
}
}, [user, hasChanges, formValues, showToast, isSuperuserUser]);
}, [canEditUsers, user, hasChanges, formValues, showToast, isSuperuserUser]);
const fetchSecurityPhrase = useCallback(async () => {
if (!canViewSecurityPhrase || !numericUserId) {
return;
}
setSecurityPhraseLoading(true);
setSecurityPhraseError("");
try {
const response = await axios.get(`/api/users/${numericUserId}/security-phrase`);
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
setSecurityPhraseWords(words);
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Sicherheitsschlüssel konnte nicht geladen werden.";
if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer wurde nicht gefunden.";
} else if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
message = "Keine Berechtigung zum Anzeigen des Sicherheitsschlüssels.";
}
setSecurityPhraseError(message);
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(null);
} finally {
setSecurityPhraseLoading(false);
}
}, [canViewSecurityPhrase, numericUserId]);
const handleReloadSecurityPhrase = useCallback(() => {
if (securityPhraseLoading || renewingSecurityPhrase) {
return;
}
fetchSecurityPhrase();
}, [fetchSecurityPhrase, renewingSecurityPhrase, securityPhraseLoading]);
const handleRenewSecurityPhrase = useCallback(async () => {
if (!canRenewSecurityPhrase || !numericUserId || maintenanceActive || renewingSecurityPhrase) {
return;
}
setRenewingSecurityPhrase(true);
setSecurityPhraseError("");
try {
const response = await axios.post(`/api/users/${numericUserId}/security-phrase/renew`);
const words = Array.isArray(response.data?.item?.words) ? response.data.item.words : [];
setSecurityPhraseWords(words);
setSecurityPhraseDownloadedAt(response.data?.item?.downloadedAt || null);
showToast({
variant: "success",
title: "Sicherheitsschlüssel erneuert",
description: "Der Benutzer muss den neuen Schlüssel erneut herunterladen."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Der Sicherheitsschlüssel konnte nicht erneuert werden.";
if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer wurde nicht gefunden.";
} else if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
message = "Keine Berechtigung zum Erneuern des Sicherheitsschlüssels.";
}
setSecurityPhraseError(message);
} finally {
setRenewingSecurityPhrase(false);
}
}, [
canRenewSecurityPhrase,
numericUserId,
maintenanceActive,
renewingSecurityPhrase,
showToast
]);
const avatarLabel = useMemo(() => {
const source = (formValues.username || formValues.email || "").trim();
@@ -381,6 +489,37 @@ export function UserDetails() {
return user?.avatarColor || "";
}, [formValues.avatarColor, user]);
const securityPhraseRows = useMemo(() => {
if (!Array.isArray(securityPhraseWords) || securityPhraseWords.length === 0) {
return [];
}
const rows = [];
for (let index = 0; index < securityPhraseWords.length; index += 4) {
rows.push(securityPhraseWords.slice(index, index + 4));
}
return rows;
}, [securityPhraseWords]);
const securityPhraseStatus = useMemo(() => {
if (!securityPhraseDownloadedAt) {
return {
text: "Noch nicht heruntergeladen",
tone: "text-red-500"
};
}
const date = new Date(securityPhraseDownloadedAt);
if (Number.isNaN(date.getTime())) {
return {
text: "Zuletzt heruntergeladen: unbekannt",
tone: "text-blue-gray-500"
};
}
return {
text: `Zuletzt heruntergeladen: ${date.toLocaleString("de-DE")}`,
tone: "text-blue-gray-500"
};
}, [securityPhraseDownloadedAt]);
const filteredGroups = useMemo(() => {
if (!Array.isArray(availableGroups)) {
return [];
@@ -402,9 +541,25 @@ export function UserDetails() {
}
}, [filteredGroups, formValues.groupId, isSuperuserUser]);
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading;
const avatarSelectDisabled = maintenanceActive || savingUser || !user;
const inputDisabled = maintenanceActive || savingUser || !user;
useEffect(() => {
if (!hasLoaded) {
return;
}
if (!canViewSecurityPhrase || !numericUserId) {
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(null);
setSecurityPhraseError("");
setSecurityPhraseLoading(false);
return;
}
fetchSecurityPhrase();
}, [hasLoaded, canViewSecurityPhrase, fetchSecurityPhrase, numericUserId]);
const inputDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const selectDisabled = maintenanceActive || savingUser || !user || groupsLoading || !canEditUsers || isSuperuserUser;
const avatarSelectDisabled = maintenanceActive || savingUser || !user || !canEditUsers;
const groupSelectValue = formValues.groupId ? String(formValues.groupId) : "";
return (
@@ -523,11 +678,6 @@ export function UserDetails() {
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Globale Rolle
</Typography>
{groupsError && !isSuperuserUser && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
{isSuperuserUser ? (
<Typography className="block text-xs font-semibold uppercase text-blue-gray-500">
Die Rolle für den Superuser kann nicht geändert werden.
@@ -615,6 +765,99 @@ export function UserDetails() {
</div>
</div>
{canViewSecurityPhrase && (
<div className="mt-10 border-t border-blue-gray-50 pt-8">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<Typography variant="h6" color="blue-gray">
Sicherheitsschlüssel
</Typography>
<Typography className={`text-sm ${securityPhraseStatus.tone}`}>
{securityPhraseStatus.text}
</Typography>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outlined"
color="blue"
className="normal-case"
onClick={handleReloadSecurityPhrase}
disabled={
!canViewSecurityPhrase ||
securityPhraseLoading ||
renewingSecurityPhrase
}
>
Aktualisieren
</Button>
{canRenewSecurityPhrase && (
<Button
color="red"
className="normal-case"
onClick={handleRenewSecurityPhrase}
disabled={
maintenanceActive ||
securityPhraseLoading ||
renewingSecurityPhrase
}
>
{renewingSecurityPhrase ? "Erneuert ..." : "Schlüssel erneuern"}
</Button>
)}
</div>
</div>
{securityPhraseLoading ? (
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Sicherheitsschlüssel wird geladen ...</span>
</div>
) : securityPhraseError ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<span>{securityPhraseError}</span>
<Button
variant="text"
size="sm"
color="red"
className="normal-case"
onClick={handleReloadSecurityPhrase}
disabled={securityPhraseLoading || renewingSecurityPhrase}
>
Erneut laden
</Button>
</div>
) : securityPhraseRows.length > 0 ? (
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 p-4">
<div className="space-y-2 text-center font-mono text-base tracking-wide text-blue-gray-900 md:text-lg">
{securityPhraseRows.map((row, rowIndex) => (
<div
key={`security-phrase-row-${rowIndex}`}
className="flex flex-wrap items-center justify-center gap-4"
>
{row.map((word, wordIndex) => (
<span
key={`security-phrase-${rowIndex}-${wordIndex}`}
className="uppercase"
>
{word}
</span>
))}
</div>
))}
</div>
</div>
) : (
<Typography className="text-sm text-blue-gray-500">
Kein Sicherheitsschlüssel verfügbar.
</Typography>
)}
{canRenewSecurityPhrase && (
<Typography className="mt-3 text-sm text-blue-gray-500">
Nach dem Erneuern muss der Benutzer den neuen Sicherheitsschlüssel erneut herunterladen.
</Typography>
)}
</div>
)}
</CardBody>
</Card></div>
</>
+494 -30
View File
@@ -10,11 +10,16 @@ import {
Select,
Option,
Input,
Chip
Chip,
Radio,
List,
ListItem,
ListItemPrefix
} from "@material-tailwind/react";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const _ = AVATAR_COLORS.join(" ");
@@ -53,34 +58,158 @@ const buildInitialFormValues = (group) => {
};
};
const LEVEL_OPTIONS = [
{ value: "full", label: "Vollzugriff" },
{ value: "read", label: "Nur lesen" },
{ value: "none", label: "Kein Zugriff" }
];
export function UserGroupDetail() {
const { groupId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [group, setGroup] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
const initialFormValuesRef = useRef(buildInitialFormValues(null));
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [hasLoaded, setHasLoaded] = useState(false);
const [savingGroup, setSavingGroup] = useState(false);
const [saveError, setSaveError] = useState("");
const [permissionSections, setPermissionSections] = useState([]);
const [permissionSelection, setPermissionSelection] = useState({});
const [permissionDefaults, setPermissionDefaults] = useState({});
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const numericGroupId = useMemo(() => {
const candidate = Number(groupId);
return Number.isFinite(candidate) ? candidate : null;
}, [groupId]);
const fetchGroupPermissions = useCallback(
async (targetGroupId) => {
const numericTargetId = Number(targetGroupId);
if (!Number.isFinite(numericTargetId) || numericTargetId <= 0) {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
return;
}
setPermissionsLoading(true);
setPermissionsError("");
try {
const response = await axios.get(`/api/groups/${numericTargetId}/permissions`);
const rawSections = Array.isArray(response.data?.sections) ? response.data.sections : [];
const rawValues =
response.data?.values && typeof response.data.values === "object" ? response.data.values : {};
const defaults = {};
const initialSelection = {};
const normalizeItem = (item, index) => {
if (!item || typeof item !== "object") {
return null;
}
const defaultLevel =
typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none";
defaults[item.key] = defaultLevel;
const assigned = rawValues[item.key];
initialSelection[item.key] = typeof assigned === "string" && assigned ? assigned : defaultLevel;
const availableLevels =
Array.isArray(item.availableLevels) && item.availableLevels.length
? item.availableLevels
: LEVEL_OPTIONS.map((option) => option.value);
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
return {
...item,
availableLevels,
dependencies,
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index
};
};
const normalizedSections = rawSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items)
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
const sectionGroups = Array.isArray(section.groups)
? section.groups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items)
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
return {
...group,
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
items: groupItems
};
})
: [];
return {
...section,
sortOrder: typeof section.sortOrder === "number" ? section.sortOrder : sectionIndex,
hasNavigation: Boolean(section.hasNavigation),
items: sectionItems,
groups: sectionGroups
};
});
setPermissionSections(normalizedSections);
setPermissionDefaults(defaults);
setPermissionSelection({ ...initialSelection });
setInitialPermissionSelection({ ...initialSelection });
} catch (err) {
let message = "Die Berechtigungen konnten nicht geladen werden.";
if (err.response?.data?.error === "GROUP_NOT_FOUND") {
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
}
setPermissionsError(message);
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setInitialPermissionSelection({});
showToast({
variant: "error",
title: "Fehler beim Laden",
description: message
});
} finally {
setPermissionsLoading(false);
}
},
[showToast]
);
const fetchGroupDetails = useCallback(async () => {
if (!numericGroupId) {
setError("Ungültige Gruppen-ID.");
setGroup(null);
setFormValues(buildInitialFormValues(null));
initialFormValuesRef.current = buildInitialFormValues(null);
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setHasLoaded(true);
return;
}
@@ -99,6 +228,25 @@ export function UserGroupDetail() {
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
const superuserDetected = (item.name || "").toLowerCase() === "superuser";
if (superuserDetected) {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
} else if (canViewPermissionSettings) {
fetchGroupPermissions(item.id);
} else {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
}
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Gruppendetails konnten nicht geladen werden.";
@@ -114,6 +262,11 @@ export function UserGroupDetail() {
setGroup(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setPermissionsLoading(false);
setError(message);
showToast({
variant: "error",
@@ -124,13 +277,13 @@ export function UserGroupDetail() {
setLoading(false);
setHasLoaded(true);
}
}, [numericGroupId, showToast]);
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
useEffect(() => {
fetchGroupDetails();
}, [fetchGroupDetails]);
const hasChanges = useMemo(() => {
const detailsChanged = useMemo(() => {
if (!hasLoaded || !group) {
return false;
}
@@ -156,6 +309,28 @@ export function UserGroupDetail() {
);
}, [formValues, hasLoaded, group]);
const permissionsChanged = useMemo(() => {
if (isSuperuserGroup) {
return false;
}
const initial = initialPermissionSelection || {};
const current = permissionSelection || {};
const allKeys = new Set([...Object.keys(initial), ...Object.keys(current)]);
for (const key of allKeys) {
const initialValue = initial[key] ?? null;
const currentValue = current[key] ?? null;
if (initialValue !== currentValue) {
return true;
}
}
return false;
}, [permissionSelection, initialPermissionSelection, isSuperuserGroup]);
const hasChanges = useMemo(
() => detailsChanged || permissionsChanged,
[detailsChanged, permissionsChanged]
);
const handleNameChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
@@ -180,7 +355,21 @@ export function UserGroupDetail() {
}, []);
const handleSaveGroup = useCallback(async () => {
if (!group || !hasChanges) {
if (!group || (!detailsChanged && !permissionsChanged)) {
return;
}
if (permissionsChanged && isSuperuserGroup) {
return;
}
if (detailsChanged && !canEditGroupDetails) {
setSaveError("Dir fehlt die Berechtigung, diese Gruppe zu bearbeiten.");
return;
}
if (permissionsChanged && !canAdjustPermissions) {
setSaveError("Dir fehlt die Berechtigung, die Gruppenrechte anzupassen.");
return;
}
@@ -188,29 +377,51 @@ export function UserGroupDetail() {
setSaveError("");
try {
const payload = {
avatarColor: formValues.avatarColor
};
if (detailsChanged) {
const payload = {
avatarColor: formValues.avatarColor
};
if (!isSuperuserGroup) {
payload.name = formValues.name;
payload.description = formValues.description;
if (!isSuperuserGroup) {
payload.name = formValues.name;
payload.description = formValues.description;
}
const response = await axios.put(`/api/groups/${group.id}`, payload);
const updated = mapGroup(response.data?.item || response.data?.group);
if (updated?.id) {
setGroup(updated);
const nextInitial = buildInitialFormValues(updated);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
}
}
const response = await axios.put(`/api/groups/${group.id}`, payload);
const updated = mapGroup(response.data?.item || response.data?.group);
setGroup(updated);
const nextInitial = buildInitialFormValues(updated);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
showToast({
variant: "success",
title: "Gruppe gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
if (permissionsChanged && !isSuperuserGroup) {
await axios.put(`/api/groups/${group.id}/permissions`, {
values: permissionSelection
});
setInitialPermissionSelection({ ...permissionSelection });
}
if (detailsChanged) {
showToast({
variant: "success",
title: "Gruppe gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
} else if (permissionsChanged) {
showToast({
variant: "success",
title: "Berechtigungen gespeichert",
description: "Die Berechtigungen wurden erfolgreich gespeichert."
});
}
setSaveError("");
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Die Gruppe konnte nicht gespeichert werden.";
let message = "Die Änderungen konnten nicht gespeichert werden.";
if (serverError === "GROUP_NAME_REQUIRED") {
message = "Bitte einen Gruppennamen angeben.";
@@ -221,7 +432,13 @@ export function UserGroupDetail() {
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Die Benutzergruppe wurde nicht gefunden.";
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
message = "Für die Superuser-Gruppe kann nur die Avatar-Farbe angepasst werden.";
message = "Für die Superuser-Gruppe können Berechtigungen nicht angepasst werden.";
} else if (serverError === "PERMISSION_INVALID_PAYLOAD") {
message = "Ungültige Berechtigungsdaten übermittelt.";
} else if (serverError === "PERMISSION_INVALID_LEVEL") {
message = "Für mindestens eine Berechtigung wurde ein nicht erlaubter Wert übermittelt.";
} else if (serverError === "PERMISSION_UNKNOWN_KEY") {
message = "Es wurde eine unbekannte Berechtigung übermittelt.";
}
setSaveError(message);
@@ -233,7 +450,17 @@ export function UserGroupDetail() {
} finally {
setSavingGroup(false);
}
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
}, [
group,
detailsChanged,
permissionsChanged,
formValues,
showToast,
isSuperuserGroup,
permissionSelection,
canEditGroupDetails,
canAdjustPermissions
]);
const avatarLabel = useMemo(() => {
const source = (formValues.name || formValues.description || "").trim();
@@ -250,10 +477,168 @@ export function UserGroupDetail() {
return group?.avatarColor || "";
}, [formValues.avatarColor, group]);
const selectDisabled = maintenanceActive || savingGroup || !group;
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
const selectValue = formValues.avatarColor || "";
const handlePermissionChange = useCallback((permissionKey, value) => {
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
return;
}
setPermissionSelection((prev) => {
if (prev[permissionKey] === value) {
return prev;
}
return {
...prev,
[permissionKey]: value
};
});
}, [canAdjustPermissions, isSuperuserGroup]);
const getPermissionValue = useCallback(
(permissionKey) => {
if (!permissionKey) {
return null;
}
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
return permissionSelection[permissionKey];
}
if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
return permissionDefaults[permissionKey];
}
return null;
},
[permissionSelection, permissionDefaults]
);
const radioIcon = useMemo(
() => (
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-400 bg-black" />
),
[]
);
const radioCheckedIcon = useMemo(
() => (
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
),
[]
);
const isPermissionRowVisible = useCallback(
(row) => {
if (!row || !row.key) {
return false;
}
const dependencies = Array.isArray(row.dependencies) ? row.dependencies : [];
return dependencies.every((dependency) => {
if (!dependency || !dependency.key) {
return true;
}
const value = getPermissionValue(dependency.key) ?? "none";
const requirement = dependency.requiredLevel || "!=none";
if (requirement === "!=none") {
return value !== "none";
}
if (requirement.startsWith("!=")) {
return value !== requirement.substring(2);
}
if (requirement.startsWith("=")) {
return value === requirement.substring(1);
}
if (!requirement) {
return true;
}
return value === requirement;
});
},
[getPermissionValue]
);
const renderPermissionRow = (row, ownerKey, options = {}) => {
if (!isPermissionRowVisible(row)) {
return null;
}
const rowName = `permission-${ownerKey}-${row.key}`;
const currentValue = getPermissionValue(row.key) ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
"flex flex-col gap-2.5 px-4 py-4 md:flex-row md:items-center md:gap-6",
addTopBorder ? "border-t border-blue-gray-100" : ""
]
.filter(Boolean)
.join(" ");
const availableLevels = new Set(
Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value)
);
return (
<div key={`${ownerKey}-${row.key}`} className={rowClasses}>
<div className="text-sm font-medium leading-5 text-blue-gray-600 md:w-1/2">
{row.label}
</div>
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
{LEVEL_OPTIONS.map((option) => {
const optionId = `${rowName}-${option.value}`;
const checked = currentValue === option.value;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup;
return (
<ListItem
key={`${ownerKey}-${row.key}-${option.value}`}
className="w-full min-w-[140px] p-0 md:w-auto md:flex-1"
>
<label
htmlFor={optionId}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
>
<ListItemPrefix className="flex-shrink-0">
<Radio
id={optionId}
name={rowName}
value={option.value}
checked={checked}
onChange={() => handlePermissionChange(row.key, option.value)}
disabled={disabled}
ripple={false}
className="h-4 w-4 hover:before:opacity-0"
containerProps={{
className: "p-0"
}}
icon={radioIcon}
checkedIcon={radioCheckedIcon}
/>
</ListItemPrefix>
<Typography
color="blue-gray"
className={`text-sm font-medium leading-5 whitespace-nowrap ${
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
>
{option.label}
</Typography>
</label>
</ListItem>
);
})}
</List>
</div>
);
};
return (
<>
<div className="mt-12 flex flex-col gap-12">
@@ -415,11 +800,90 @@ export function UserGroupDetail() {
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
</div>
</div>
<div className="lg:col-span-2 xl:col-span-3">
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<div className="border-b border-blue-gray-100 px-4 py-4">
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
Für diese Gruppe sind keine Berechtigungen definiert.
</div>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
key={section.key || sectionIndex}
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
>
<div className="border-b border-blue-gray-100 px-4 py-4">
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
{section.title}
</Typography>
</div>
{sectionItems.map((row, rowIndex) =>
renderPermissionRow(row, section.key || sectionIndex, {
addTopBorder: rowIndex !== 0
})
)}
{sectionGroups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items) ? group.items : [];
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
if (!hasVisibleRows) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
</div>
);
})
))}
</div>
)}
</div>
</div>
</CardBody>
</Card></div>
</>
);
</Card>
</div>
</>
);
}
export default UserGroupDetail;
export default UserGroupDetail;
+67 -54
View File
@@ -15,6 +15,7 @@ import {
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
export function Usergroups() {
const [groups, setGroups] = useState([]);
@@ -31,6 +32,10 @@ export function Usergroups() {
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const navigate = useNavigate();
const { hasPermission } = useAuth();
const canEditGroups = hasPermission("user-groups-edit", "full");
const canDeleteGroups = hasPermission("user-groups-delete", "full");
const {
page,
@@ -188,6 +193,10 @@ export function Usergroups() {
if (maintenanceActive) {
return;
}
if (!canEditGroups) {
setCreateGroupError("Du verfügst nicht über die Berechtigung zum Anlegen von Gruppen.");
return;
}
const trimmedName = newGroupName.trim();
if (!trimmedName) {
setCreateGroupError("Gruppenname ist erforderlich.");
@@ -228,6 +237,7 @@ export function Usergroups() {
}
}, [
maintenanceActive,
canEditGroups,
newGroupName,
newGroupDescription,
showToast,
@@ -242,6 +252,10 @@ export function Usergroups() {
return;
}
if (!canDeleteGroups) {
return;
}
const isSuperuserGroup = (group?.name || "").toLowerCase() === "superuser";
if (isSuperuserGroup) {
showToast({
@@ -308,7 +322,7 @@ export function Usergroups() {
} finally {
setDeletingGroupId(null);
}
}, [maintenanceActive, showToast, fetchGroups]);
}, [maintenanceActive, canDeleteGroups, showToast, fetchGroups]);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
@@ -327,43 +341,45 @@ export function Usergroups() {
</Typography>
</CardHeader>
<CardBody className="pt-0">
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neue Benutzergruppe anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Der Gruppenname ist Pflicht, die Beschreibung optional.
</Typography>
{createGroupError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createGroupError}
{canEditGroups && (
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neue Benutzergruppe anlegen
</Typography>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Der Gruppenname ist Pflicht, die Beschreibung optional.
</Typography>
{createGroupError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createGroupError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Gruppenname"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
<Input
label="Beschreibung (optional)"
value={newGroupDescription}
onChange={(event) => setNewGroupDescription(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
Formular zurücksetzen
</Button>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Gruppenname"
value={newGroupName}
onChange={(event) => setNewGroupName(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
<Input
label="Beschreibung (optional)"
value={newGroupDescription}
onChange={(event) => setNewGroupDescription(event.target.value)}
disabled={createGroupDisabled}
crossOrigin=""
/>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateGroup} disabled={createGroupDisabled}>
{creatingGroup ? "Speichert ..." : "Gruppe anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewGroupForm} disabled={creatingGroup}>
Formular zurücksetzen
</Button>
</div>
</div>
)}
<div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -475,24 +491,21 @@ export function Usergroups() {
>
Details
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteGroup(group)}
disabled={
maintenanceActive ||
deletingGroupId === group.id ||
Number(group.memberCount) > 0 ||
isSuperuserGroup
}
>
{isSuperuserGroup
? ""
: deletingGroupId === group.id
? "Löscht ..."
: "Löschen"}
</Button>
{canDeleteGroups && !isSuperuserGroup && (
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteGroup(group)}
disabled={
maintenanceActive ||
deletingGroupId === group.id ||
Number(group.memberCount) > 0
}
>
{deletingGroupId === group.id ? "Löscht ..." : "Löschen"}
</Button>
)}
</div>
</td>
</tr>
+129 -100
View File
@@ -15,6 +15,7 @@ import { useNavigate } from "react-router-dom";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
@@ -64,6 +65,10 @@ export function Users() {
const maintenanceActive = Boolean(maintenance?.active);
const navigate = useNavigate();
const noop = useCallback(() => { }, []);
const { hasPermission } = useAuth();
const canManageUsers = hasPermission("users-edit", "full");
const canDeleteUsers = hasPermission("users-delete", "full");
const {
page,
@@ -251,7 +256,9 @@ export function Users() {
if (maintenanceActive || !user?.id) {
return;
}
if (!canDeleteUsers) {
return;
}
const isSuperuserUser = Array.isArray(user?.groups)
? user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser")
: false;
@@ -307,12 +314,15 @@ export function Users() {
} finally {
setDeletingUserId(null);
}
}, [maintenanceActive, showToast, fetchUsers]);
}, [maintenanceActive, canDeleteUsers, showToast, fetchUsers]);
const handleToggleUserStatus = useCallback(async (user) => {
if (!user?.id) {
return;
}
if (!canManageUsers) {
return;
}
if (maintenanceActive) {
showToast({
variant: "error",
@@ -376,12 +386,16 @@ export function Users() {
} finally {
setTogglingUserId(null);
}
}, [maintenanceActive, showToast, fetchUsers]);
}, [maintenanceActive, canManageUsers, showToast, fetchUsers]);
const handleCreateUser = useCallback(async () => {
if (maintenanceActive) {
return;
}
if (!canManageUsers) {
setCreateError("Du verfügst nicht über die Berechtigung zum Anlegen von Benutzern.");
return;
}
const trimmedUsername = newUsername.trim();
if (!trimmedUsername) {
setCreateError("Benutzername ist erforderlich.");
@@ -454,6 +468,7 @@ export function Users() {
}
}, [
maintenanceActive,
canManageUsers,
newUsername,
newEmail,
newPassword,
@@ -499,84 +514,86 @@ export function Users() {
<CardBody className="pt-0">
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen
</Typography>
{hasSelectableGroups ? (
<>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography>
{createError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createError}
{canManageUsers && (
<div className="mb-8 rounded-lg border border-blue-gray-100 bg-white p-4 shadow-sm">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Benutzer anlegen
</Typography>
{hasSelectableGroups ? (
<>
<Typography variant="small" className="text-sm text-stormGrey-500 mb-4">
Benutzername, Passwort (mindestens 8 Zeichen) und eine globale Rolle sind Pflichtfelder.
</Typography>
{createError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{createError}
</div>
)}
{groupsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
type="password"
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Select
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
)}
{groupsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: ""}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Input
label="Benutzername"
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
label="E-Mail (optional)"
value={newEmail}
onChange={(event) => setNewEmail(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Input
type="password"
label="Passwort"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
disabled={maintenanceActive || creatingUser}
crossOrigin=""
/>
<Select
label="Globale Rolle"
variant="outlined"
value={newGroupId}
onChange={setNewGroupId}
disabled={groupSelectDisabled}
selected={renderSelectedGroup}
>
<Option value="">Bitte auswählen</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
Formular zurücksetzen
</Button>
</div>
</>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
</div>
<div className="mt-4 text-sm text-stormGrey-500">
{groupsLoading
? "Benutzergruppen werden geladen ..."
: ""}
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button color="green" onClick={handleCreateUser} disabled={createDisabled}>
{creatingUser ? "Speichert ..." : "Benutzer anlegen"}
</Button>
<Button variant="text" color="blue-gray" onClick={resetNewUserForm} disabled={creatingUser}>
Formular zurücksetzen
</Button>
</div>
</>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Es existiert keine verfügbare Benutzergruppe. Bitte lege zuerst eine Gruppe an.
</div>
)}
</div>
)}
</div>
)}
<div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
@@ -674,20 +691,34 @@ export function Users() {
)}
</td>
<td className="px-6 py-4">
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={maintenanceActive || togglingUserId === user.id || (Array.isArray(user.groups) && user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))}
>
{canManageUsers ? (
<button
type="button"
className={`inline-flex items-center rounded-full px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${maintenanceActive ? "cursor-not-allowed" : "cursor-pointer"}`}
onClick={() => handleToggleUserStatus(user)}
disabled={
maintenanceActive ||
togglingUserId === user.id ||
(Array.isArray(user.groups) &&
user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser" && user.isActive))
}
>
<Chip
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
className="pointer-events-none"
/>
</button>
) : (
<Chip
value={togglingUserId === user.id ? "Wechselt ..." : user.isActive ? "Aktiv" : "Deaktiviert"}
value={user.isActive ? "Aktiv" : "Deaktiviert"}
size="sm"
color={user.isActive ? "green" : "red"}
variant="ghost"
className="pointer-events-none"
/>
</button>
)}
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
@@ -709,19 +740,17 @@ export function Users() {
>
Details
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceActive || deletingUserId === user.id || isSuperuserUser}
>
{isSuperuserUser
? ""
: deletingUserId === user.id
? "Löscht ..."
: "Löschen"}
</Button>
{canDeleteUsers && !isSuperuserUser && (
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteUser(user)}
disabled={maintenanceActive || deletingUserId === user.id}
>
{deletingUserId === user.id ? "Löscht ..." : "Löschen"}
</Button>
)}
</div>
</td>
</tr>
+42 -6
View File
@@ -4,11 +4,14 @@ import {
ListBulletIcon,
ServerStackIcon,
RectangleStackIcon,
ArrowLeftOnRectangleIcon,
UserIcon,
UserGroupIcon
UserGroupIcon,
KeyIcon
} from "@heroicons/react/24/solid";
import { Stacks, Maintenance, Logs, Users, Usergroups } from "@/pages/dashboard";
import { SignIn, SignUp } from "@/pages/auth";
import { SignIn, SignUp, SignOut, ForgotPassword } from "@/pages/auth";
import PermissionGate from "@/components/PermissionGate.jsx";
const icon = {
className: "w-5 h-5 text-inherit",
@@ -23,30 +26,51 @@ export const routes = [
name: "stacks",
path: "/stacks",
element: <Stacks />,
permission: null,
},
{
icon: <WrenchScrewdriverIcon {...icon} />,
name: "wartung",
path: "/maintenance",
element: <Maintenance />,
element: (
<PermissionGate permission="maintenance-access" requiredLevel="read">
<Maintenance />
</PermissionGate>
),
permission: { key: "maintenance-access", requiredLevel: "read" },
},
{
icon: <ListBulletIcon {...icon} />,
name: "logs",
path: "/logs",
element: <Logs />,
element: (
<PermissionGate permission="logs-access" requiredLevel="full">
<Logs />
</PermissionGate>
),
permission: { key: "logs-access", requiredLevel: "full" },
},
{
icon: <UserIcon {...icon} />,
name: "benutzer",
path: "/users",
element: <Users />,
element: (
<PermissionGate permission="users-access" requiredLevel="read">
<Users />
</PermissionGate>
),
permission: { key: "users-access", requiredLevel: "read" },
},
{
icon: <UserGroupIcon {...icon} />,
name: "rechtegruppen",
path: "/usergroups",
element: <Usergroups />,
element: (
<PermissionGate permission="user-groups-access" requiredLevel="read">
<Usergroups />
</PermissionGate>
),
permission: { key: "user-groups-access", requiredLevel: "read" },
},
],
},
@@ -60,6 +84,18 @@ export const routes = [
path: "/sign-in",
element: <SignIn />,
},
{
icon: <KeyIcon {...icon} />,
name: "passwort vergessen",
path: "/forgot-password",
element: <ForgotPassword />,
},
{
icon: <ArrowLeftOnRectangleIcon {...icon} />,
name: "log out",
path: "/logout",
element: <SignOut />,
},
{
icon: <RectangleStackIcon {...icon} />,
name: "sign up",
+29 -11
View File
@@ -1,18 +1,15 @@
import PropTypes from "prop-types";
import { Link, NavLink } from "react-router-dom";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
Avatar,
Button,
IconButton,
Typography,
} from "@material-tailwind/react";
import { Avatar, Button, IconButton, Typography } from "@material-tailwind/react";
import { useMaterialTailwindController, setOpenSidenav } from "@/components";
import { useAuth } from "@/components/AuthProvider.jsx";
import logo from "@/assets/images/stackpulse.png";
export function Sidenav({ brandImg, brandName, routes }) {
const [controller, dispatch] = useMaterialTailwindController();
const { sidenavColor, sidenavType, openSidenav } = controller;
const { initialized: authInitialized, hasPermission } = useAuth();
const sidenavTypes = {
dark: "bg-gradient-to-br from-gray-800 to-gray-900",
white: "bg-white shadow-sm",
@@ -38,8 +35,28 @@ export function Sidenav({ brandImg, brandName, routes }) {
</IconButton>
</div>
<div className="m-4">
{routes.map(({ layout, title, pages }, key) => (
<ul key={key} className="mb-4 flex flex-col gap-1">
{routes.map(({ layout, title, pages }, key) => {
const visiblePages = pages.filter(({ permission }) => {
if (!permission || !permission.key) {
return true;
}
if (!authInitialized) {
return false;
}
const requiredLevel = permission.requiredLevel || permission.level || "full";
return hasPermission(permission.key, requiredLevel);
});
if (layout !== "dashboard" && layout !== "auth") {
return null;
}
if (visiblePages.length === 0 && !title) {
return null;
}
return (
<ul key={key} className="mb-4 flex flex-col gap-1">
{title && (
<li className="mx-3.5 mt-4 mb-2">
<Typography
@@ -51,7 +68,7 @@ export function Sidenav({ brandImg, brandName, routes }) {
</Typography>
</li>
)}
{pages.map(({ icon, name, path }) => (
{visiblePages.map(({ icon, name, path }) => (
<li key={name}>
<NavLink to={`/${layout}${path}`}>
{({ isActive }) => (
@@ -78,9 +95,10 @@ export function Sidenav({ brandImg, brandName, routes }) {
)}
</NavLink>
</li>
))}
))}
</ul>
))}
);
})}
</div>
</aside>
);