import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; import { useParams } from "react-router-dom"; import { Card, CardBody, Typography, Button, Spinner, Select, Option, Input, Switch } from "@material-tailwind/react"; 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(" "); const normalizeUserGroups = (rawGroups) => { if (!Array.isArray(rawGroups)) { return []; } return rawGroups .map((group) => { if (group && typeof group === "object") { const id = Number(group.id); const name = typeof group.name === "string" ? group.name : ""; if (!name) { return null; } return { id: Number.isFinite(id) ? id : null, name }; } if (typeof group === "string") { const name = group.trim(); return name ? { id: null, name } : null; } return null; }) .filter(Boolean); }; const mapUser = (item) => ({ id: item?.id ?? null, username: item?.username || "", email: item?.email || "", isActive: Boolean(item?.isActive), avatarColor: item?.avatarColor || null, lastLogin: item?.lastLogin || null, createdAt: item?.createdAt || null, updatedAt: item?.updatedAt || null, groups: normalizeUserGroups(item?.groups), securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null }); const extractPrimaryGroupId = (user) => { if (!user || !Array.isArray(user.groups) || user.groups.length === 0) { return null; } const firstValid = user.groups .map((group) => Number(group.id)) .find((id) => Number.isFinite(id) && id > 0); return Number.isFinite(firstValid) ? firstValid : null; }; const buildInitialFormValues = (user) => { if (!user) { return { username: "", email: "", password: "", groupId: null, avatarColor: "" }; } const primaryGroupId = extractPrimaryGroupId(user); return { username: user.username || "", email: user.email || "", password: "", groupId: Number.isFinite(primaryGroupId) ? primaryGroupId : null, avatarColor: user.avatarColor || "" }; }; 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)); const initialFormValuesRef = useRef(buildInitialFormValues(null)); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [hasLoaded, setHasLoaded] = useState(false); const [availableGroups, setAvailableGroups] = useState([]); const [groupsLoading, setGroupsLoading] = useState(false); 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; } return user.groups.some((group) => (group?.name || "").toLowerCase() === "superuser"); }, [user]); const numericUserId = useMemo(() => { const asNumber = Number(userId); return Number.isFinite(asNumber) ? asNumber : null; }, [userId]); const fetchUserDetails = useCallback(async () => { if (!numericUserId) { setError("Ungültige Benutzer-ID."); setUser(null); setFormValues(buildInitialFormValues(null)); initialFormValuesRef.current = buildInitialFormValues(null); setHasLoaded(true); return; } setLoading(true); setError(""); try { const response = await axios.get(`/api/users/${numericUserId}`); const item = mapUser(response.data?.item); if (!item.id) { throw new Error("USER_NOT_FOUND"); } setUser(item); setSecurityPhraseWords([]); setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null); setSecurityPhraseError(""); const initialValues = buildInitialFormValues(item); initialFormValuesRef.current = { ...initialValues }; setFormValues(initialValues); setSaveError(""); } catch (err) { const serverError = err.response?.data?.error; let message = "Benutzerdetails konnten nicht geladen werden."; if (serverError === "USER_NOT_FOUND") { message = "Der angeforderte Benutzer wurde nicht gefunden."; } else if (serverError === "INVALID_USER_ID") { message = "Die angegebene Benutzer-ID ist ungültig."; } else if (err.response?.status === 404) { message = "Der angeforderte Benutzer existiert nicht."; } setUser(null); initialFormValuesRef.current = buildInitialFormValues(null); setFormValues(buildInitialFormValues(null)); setError(message); showToast({ variant: "error", title: "Fehler beim Laden", description: message }); } finally { setLoading(false); setHasLoaded(true); } }, [numericUserId, showToast]); const fetchAvailableGroups = useCallback(async () => { setGroupsLoading(true); setGroupsError(""); try { const response = await axios.get("/api/groups"); const items = Array.isArray(response.data?.items) ? response.data.items : []; const normalized = items .map((item) => ({ id: Number(item.id), name: item.name || "", description: item.description || "", memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0 })) .filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name) .sort((a, b) => a.name.localeCompare(b.name, "de-DE")); setAvailableGroups(normalized); } catch (err) { const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden."; setGroupsError(message); showToast({ variant: "error", title: "Benutzergruppen", description: message }); } finally { setGroupsLoading(false); } }, [showToast]); useEffect(() => { fetchUserDetails(); }, [fetchUserDetails]); useEffect(() => { fetchAvailableGroups(); }, [fetchAvailableGroups]); const hasChanges = useMemo(() => { if (!hasLoaded || !user) { return false; } const initial = initialFormValuesRef.current; if (!initial) { return false; } const initialUsername = initial.username || ""; const currentUsername = formValues.username || ""; const initialEmail = initial.email || ""; const currentEmail = formValues.email || ""; const initialGroupId = Number.isFinite(initial.groupId) ? Number(initial.groupId) : null; const currentGroupId = Number.isFinite(formValues.groupId) ? Number(formValues.groupId) : null; const initialAvatar = initial.avatarColor || ""; const currentAvatar = formValues.avatarColor || ""; const passwordChanged = Boolean(formValues.password && formValues.password.trim().length > 0); return ( initialUsername !== currentUsername || initialEmail !== currentEmail || (!isSuperuserUser && initialGroupId !== currentGroupId) || initialAvatar !== currentAvatar || passwordChanged ); }, [formValues, hasLoaded, user, isSuperuserUser]); const renderSelectedAvatarLabel = useCallback( (element) => { if (element?.props?.children) { return element.props.children; } if (!formValues.avatarColor) { return "Standardfarbe"; } return formValues.avatarColor; }, [formValues.avatarColor] ); 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, groupId: null })); return; } const numeric = Number(value); setFormValues((prev) => ({ ...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 (!canEditUsers || !user || !hasChanges) { return; } setSavingUser(true); setSaveError(""); try { const payload = { username: formValues.username, email: formValues.email, password: formValues.password, avatarColor: formValues.avatarColor }; if (!isSuperuserUser) { payload.groupId = formValues.groupId; } const response = await axios.put(`/api/users/${user.id}`, payload); const updatedUser = mapUser(response.data?.item || response.data?.user); setUser(updatedUser); const nextInitial = buildInitialFormValues(updatedUser); initialFormValuesRef.current = { ...nextInitial }; setFormValues(nextInitial); setGroupsError(""); showToast({ variant: "success", title: "Benutzer gespeichert", description: "Die Änderungen wurden erfolgreich gespeichert." }); } catch (err) { const serverError = err.response?.data?.error; let message = "Die Benutzerdaten konnten nicht gespeichert werden."; if (serverError === "USERNAME_REQUIRED") { message = "Bitte einen Benutzernamen angeben."; } else if (serverError === "USERNAME_TAKEN") { message = "Der Benutzername wird bereits verwendet."; } else if (serverError === "INVALID_EMAIL") { message = "Bitte eine gültige E-Mail-Adresse eingeben."; } else if (serverError === "EMAIL_TAKEN") { message = "Die E-Mail-Adresse wird bereits verwendet."; } else if (serverError === "INVALID_PASSWORD") { message = "Das Passwort ist ungültig."; } else if (serverError === "PASSWORD_TOO_SHORT") { message = "Das Passwort muss mindestens 8 Zeichen enthalten."; } else if (serverError === "INVALID_AVATAR_COLOR") { message = "Bitte eine gültige Avatar-Farbe auswählen."; } else if (serverError === "GROUP_NOT_FOUND") { message = "Die ausgewählte Benutzergruppe existiert nicht mehr."; setGroupsError(message); } setSaveError(message); showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message }); } finally { setSavingUser(false); } }, [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(); if (!source) { return "?"; } return source.charAt(0).toUpperCase(); }, [formValues.username, formValues.email]); const avatarColorClass = useMemo(() => { if (formValues.avatarColor) { return formValues.avatarColor; } 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 []; } return availableGroups.filter((group) => (group?.name || "").toLowerCase() !== "superuser"); }, [availableGroups]); useEffect(() => { if (isSuperuserUser) { return; } const currentGroupId = formValues.groupId ? Number(formValues.groupId) : null; const stillExists = filteredGroups.some((group) => Number(group.id) === currentGroupId); if (!stillExists) { setFormValues((prev) => ({ ...prev, groupId: null })); } }, [filteredGroups, formValues.groupId, isSuperuserUser]); 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 ( <>
{maintenanceActive && (
Wartungsmodus aktiv – Änderungen sind deaktiviert.
)}
{avatarLabel}
{formValues.username || "–"} {formValues.email || "–"}
{hasChanges && ( )}
{loading && !user && (
Benutzerdaten werden geladen ...
)} {error && !loading && (
{error}
)} {saveError && !loading && (
{saveError}
)}
Allgemeine Einstellungen
Benutzername
E-Mail-Adresse
Neues Passwort Das Passwort wird nur geändert, wenn ein neuer Wert eingetragen wird.
Globale Rolle {isSuperuserUser ? ( Die Rolle für den Superuser kann nicht geändert werden. ) : groupsLoading && availableGroups.length === 0 ? (
Benutzergruppen werden geladen ...
) : filteredGroups.length === 0 ? ( Es sind keine weiteren Benutzergruppen vorhanden. Bitte lege zunächst eine neue Gruppe an. ) : ( )}
Avatar-Farbe
Platform Settings
{platformSettingsData.map(({ title, options }) => (
{title}
{options.map(({ checked, label }) => ( ))}
))}
{canViewSecurityPhrase && (
Sicherheitsschlüssel {securityPhraseStatus.text}
{canRenewSecurityPhrase && ( )}
{securityPhraseLoading ? (
Sicherheitsschlüssel wird geladen ...
) : securityPhraseError ? (
{securityPhraseError}
) : securityPhraseRows.length > 0 ? (
{securityPhraseRows.map((row, rowIndex) => (
{row.map((word, wordIndex) => ( {word} ))}
))}
) : ( Kein Sicherheitsschlüssel verfügbar. )} {canRenewSecurityPhrase && ( Nach dem Erneuern muss der Benutzer den neuen Sicherheitsschlüssel erneut herunterladen. )}
)}
); } export default UserDetails;