Forgot Passaord
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user