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, 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(" "); const UPDATE_STAGE_LABELS = { initializing: "Vorbereitung", "activating-maintenance": "Wartungsmodus aktivieren", "executing-script": "Skript wird ausgeführt", waiting: "Warte auf Portainer", completed: "Abgeschlossen", failed: "Fehlgeschlagen" }; const mapGroup = (item) => ({ id: item?.id ?? null, name: item?.name || "", description: item?.description || "", avatarColor: item?.avatarColor || null, createdAt: item?.createdAt || null, updatedAt: item?.updatedAt || null, memberCount: Number.isFinite(Number(item?.memberCount)) ? Number(item.memberCount) : 0, members: Array.isArray(item?.members) ? item.members .map((member) => ({ id: member?.id ?? null, username: member?.username || "" })) .filter((member) => member.username) : [] }); const buildInitialFormValues = (group) => { if (!group) { return { name: "", description: "", avatarColor: "" }; } return { name: group.name || "", description: group.description || "", avatarColor: group.avatarColor || "" }; }; const LEVEL_OPTIONS = [ { value: "full", label: "Vollzugriff" }, { value: "read", label: "Nur lesen" }, { value: "none", label: "Kein Zugriff" } ]; const LEVEL_PRIORITY = { none: 0, read: 1, full: 2 }; const normalizePermissionLevel = (key, value) => key === "maintenance-server-delete" && value !== "full" ? "none" : value; export function UserGroupDetail() { const { groupId } = useParams(); const { showToast } = useToast(); const { maintenance: maintenanceMeta, update: updateState } = 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(maintenanceMeta?.active); const maintenanceMessage = maintenanceMeta?.message; const updateRunning = Boolean(updateState?.running); const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "–"; const maintenanceLocked = maintenanceActive || updateRunning; 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 superuserGroupLocked = isSuperuserGroup && !isSuperuserAccount; 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 key = item.key || ""; const defaultLevel = typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none"; defaults[key] = defaultLevel; const assigned = rawValues[key]; let normalizedAssigned = typeof assigned === "string" && assigned ? assigned : defaultLevel; const availableLevels = Array.isArray(item.availableLevels) && item.availableLevels.length ? item.availableLevels : LEVEL_OPTIONS.map((option) => option.value); if (!availableLevels.includes(normalizedAssigned)) { normalizedAssigned = defaultLevel; } initialSelection[key] = normalizedAssigned; 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; } setLoading(true); setError(""); try { const response = await axios.get(`/api/groups/${numericGroupId}`); const item = mapGroup(response.data?.item); if (!item.id) { throw new Error("GROUP_NOT_FOUND"); } setGroup(item); const initialValues = buildInitialFormValues(item); 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."; if (serverError === "GROUP_NOT_FOUND") { message = "Die angeforderte Benutzergruppe wurde nicht gefunden."; } else if (serverError === "INVALID_GROUP_ID") { message = "Die angegebene Gruppen-ID ist ungültig."; } else if (err.response?.status === 404) { message = "Die angeforderte Benutzergruppe existiert nicht."; } setGroup(null); initialFormValuesRef.current = buildInitialFormValues(null); setFormValues(buildInitialFormValues(null)); setPermissionSections([]); setPermissionSelection({}); setPermissionDefaults({}); setPermissionsError(""); setPermissionsLoading(false); setError(message); showToast({ variant: "error", title: "Fehler beim Laden", description: message }); } finally { setLoading(false); setHasLoaded(true); } }, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]); useEffect(() => { fetchGroupDetails(); }, [fetchGroupDetails]); const detailsChanged = useMemo(() => { if (!hasLoaded || !group) { return false; } const initial = initialFormValuesRef.current; if (!initial) { return false; } const initialName = initial.name || ""; const currentName = formValues.name || ""; const initialDescription = initial.description || ""; const currentDescription = formValues.description || ""; const initialAvatar = initial.avatarColor || ""; const currentAvatar = formValues.avatarColor || ""; return ( initialName !== currentName || initialDescription !== currentDescription || initialAvatar !== currentAvatar ); }, [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) => ({ ...prev, name: value })); }, []); const handleDescriptionChange = useCallback((event) => { const { value } = event.target; setFormValues((prev) => ({ ...prev, description: value })); }, []); const handleAvatarColorChange = useCallback((value) => { setFormValues((prev) => ({ ...prev, avatarColor: value || "" })); }, []); const handleSaveGroup = useCallback(async () => { if (!group || (!detailsChanged && !permissionsChanged)) { 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; } setSavingGroup(true); setSaveError(""); try { if (detailsChanged) { const payload = { avatarColor: formValues.avatarColor }; 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); } } if (permissionsChanged) { await axios.put(`/api/groups/${group.id}/permissions`, { values: permissionSelection }); setPermissionSelection((prev) => ({ ...prev, ...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 Änderungen konnten nicht gespeichert werden."; if (serverError === "GROUP_NAME_REQUIRED") { message = "Bitte einen Gruppennamen angeben."; } else if (serverError === "GROUP_NAME_TAKEN") { message = "Der Gruppenname wird bereits verwendet."; } else if (serverError === "INVALID_AVATAR_COLOR") { message = "Bitte eine gültige Avatar-Farbe auswählen."; } else if (serverError === "GROUP_NOT_FOUND") { message = "Die Benutzergruppe wurde nicht gefunden."; } else if (serverError === "GROUP_SUPERUSER_PROTECTED") { 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); showToast({ variant: "error", title: "Speichern fehlgeschlagen", description: message }); } finally { setSavingGroup(false); } }, [ group, detailsChanged, permissionsChanged, formValues, showToast, isSuperuserGroup, permissionSelection, canEditGroupDetails, canAdjustPermissions ]); const avatarLabel = useMemo(() => { const source = (formValues.name || formValues.description || "").trim(); if (!source) { return "?"; } return source.charAt(0).toUpperCase(); }, [formValues.name, formValues.description]); const avatarColorClass = useMemo(() => { if (formValues.avatarColor) { return formValues.avatarColor; } return group?.avatarColor || ""; }, [formValues.avatarColor, group]); const inputDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails; const selectDisabled = maintenanceLocked || savingGroup || !group || superuserGroupLocked || !canEditGroupDetails; const selectValue = formValues.avatarColor || ""; const handlePermissionChange = useCallback((permissionKey, value) => { if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) { return; } const nextValue = normalizePermissionLevel(permissionKey, value); setPermissionSelection((prev) => { const shouldResetDelete = permissionKey === "maintenance-server-manage" && nextValue !== "full"; if (!shouldResetDelete && prev[permissionKey] === nextValue) { return prev; } const nextState = { ...prev, [permissionKey]: nextValue }; if (shouldResetDelete) { nextState["maintenance-server-delete"] = "none"; } return nextState; }); }, [canAdjustPermissions, isSuperuserGroup]); const getPermissionValue = useCallback( (permissionKey) => { if (!permissionKey) { return null; } let value = null; if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) { value = permissionSelection[permissionKey]; } else if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) { value = permissionDefaults[permissionKey]; } if (typeof value === "string") { return normalizePermissionLevel(permissionKey, value); } return value; }, [permissionSelection, permissionDefaults] ); const radioIcon = useMemo( () => ( ), [] ); const radioCheckedIcon = useMemo( () => ( ), [] ); 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 displayValue = normalizePermissionLevel(row.key, currentValue); const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "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 baseLevels = Array.isArray(row.availableLevels) && row.availableLevels.length ? row.availableLevels : LEVEL_OPTIONS.map((option) => option.value); const availableLevels = new Set( row.key === "maintenance-server-delete" ? baseLevels.filter((level) => level === "full" || level === "none") : baseLevels ); const levelOptions = LEVEL_OPTIONS; return (
{row.label}
{levelOptions.map((option) => { const optionId = `${rowName}-${option.value}`; const checked = displayValue === option.value; const requiresManageFull = row.key === "maintenance-server-delete" && option.value === "full" && (LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full; const disabled = !availableLevels.has(option.value) || permissionsLoading || savingGroup || !canAdjustPermissions || isSuperuserGroup || requiresManageFull; return ( ); })}
); }; return ( <>
{(maintenanceActive || updateRunning) && (
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}. {updateRunning && ( Phase: {updateStageLabel} )}
)}
{avatarLabel}
{formValues.name || "–"} Gruppen-ID: {group?.id ?? "–"}
{hasChanges && ( )}
{loading && !group && (
Gruppendaten werden geladen ...
)} {error && !loading && (
{error}
)} {saveError && !loading && (
{saveError}
)}
Gruppendaten {isSuperuserGroup && (
Systemgruppe – Name und Beschreibung sind geschützt. {superuserGroupLocked && ( Nur der Superuser darf die Superuser-Gruppe bearbeiten. )}
)}
Gruppenname
Beschreibung
Avatar-Farbe
Mitglieder {!group || group.members.length === 0 ? ( Aktuell sind keine Benutzer dieser Gruppe zugeordnet. ) : (
{group.members.map((member) => ( ))}
)}

Mitglieder insgesamt: {group?.memberCount ?? 0}

{(!maintenanceActive && !updateRunning) && ( <> Berechtigungen (Ansicht) {isSuperuserGroup ? (
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
) : !canViewPermissionSettings ? null : (
{permissionsLoading && (
Berechtigungen werden geladen ...
)} {!permissionsLoading && permissionsError && (
{permissionsError}
)} {!permissionsLoading && !permissionsError && (permissionSections.length === 0 ? (
Für diese Gruppe sind keine Berechtigungen definiert.
) : ( permissionSections.map((section, sectionIndex) => { const sectionItems = Array.isArray(section.items) ? section.items : []; const sectionGroups = Array.isArray(section.groups) ? section.groups : []; return (
{section.title}
{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 (
{group.title}
{groupItems.map((row, rowIndex) => renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, { addTopBorder: rowIndex !== 0 }) )}
); })}
); }) ))}
)} )}
); } export default UserGroupDetail;