961 lines
36 KiB
React
961 lines
36 KiB
React
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(
|
||
() => (
|
||
<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 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 (
|
||
<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">
|
||
{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 (
|
||
<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">
|
||
{(maintenanceActive || updateRunning) && (
|
||
<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
|
||
<div className="flex flex-col gap-1">
|
||
<span>
|
||
Wartungsmodus aktiv{maintenanceMessage ? ` – ${maintenanceMessage}` : updateRunning ? " – Portainer-Update läuft" : ""}.
|
||
</span>
|
||
{updateRunning && (
|
||
<span className="text-xs text-indigo-900">
|
||
Phase: {updateStageLabel}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="relative h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover\tbg-center">
|
||
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
|
||
</div>
|
||
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
|
||
<CardBody className="p-4">
|
||
<div className="mb-10 flex flex-wrap items-center justify-between gap-6">
|
||
<div className="flex items-center gap-6">
|
||
<div
|
||
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${avatarColorClass}`}
|
||
aria-label={formValues.name || "Gruppenavatar"}
|
||
>
|
||
{avatarLabel}
|
||
</div>
|
||
<div>
|
||
<Typography variant="h5" color="blue-gray">
|
||
{formValues.name || "–"}
|
||
</Typography>
|
||
<Typography className="text-xs font-semibold tracking-wide text-stormGrey-400">
|
||
Gruppen-ID: {group?.id ?? "–"}
|
||
</Typography>
|
||
</div>
|
||
</div>
|
||
{hasChanges && (
|
||
<Button
|
||
color="green"
|
||
className="normal-case"
|
||
onClick={handleSaveGroup}
|
||
disabled={maintenanceLocked || savingGroup}
|
||
>
|
||
{savingGroup ? "Speichert ..." : "Änderungen speichern"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{loading && !group && (
|
||
<div className="mb-6 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>Gruppendaten werden geladen ...</span>
|
||
</div>
|
||
)}
|
||
{error && !loading && (
|
||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||
{error}
|
||
</div>
|
||
)}
|
||
{saveError && !loading && (
|
||
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||
{saveError}
|
||
</div>
|
||
)}
|
||
<div className="grid-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
|
||
<div>
|
||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||
Gruppendaten
|
||
</Typography>
|
||
{isSuperuserGroup && (
|
||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||
Systemgruppe – Name und Beschreibung sind geschützt.
|
||
{superuserGroupLocked && (
|
||
<span className="mt-1 block text-xs text-amber-600">
|
||
Nur der Superuser darf die Superuser-Gruppe bearbeiten.
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
<div className="mb-6">
|
||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||
Gruppenname
|
||
</Typography>
|
||
<Input
|
||
value={formValues.name}
|
||
onChange={handleNameChange}
|
||
placeholder="Gruppenname"
|
||
disabled={inputDisabled}
|
||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||
labelProps={{
|
||
className: "before:content-none after:content-none"
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="mb-6">
|
||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||
Beschreibung
|
||
</Typography>
|
||
<Input
|
||
value={formValues.description}
|
||
onChange={handleDescriptionChange}
|
||
placeholder="Beschreibung der Gruppe"
|
||
disabled={inputDisabled}
|
||
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
|
||
labelProps={{
|
||
className: "before:content-none after:content-none"
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="mb-6">
|
||
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
|
||
Avatar-Farbe
|
||
</Typography>
|
||
<Select
|
||
label="Avatar-Farbe auswählen"
|
||
variant="outlined"
|
||
value={selectValue}
|
||
onChange={handleAvatarColorChange}
|
||
disabled={selectDisabled}
|
||
selected={(element) => {
|
||
if (element?.props?.children) {
|
||
return element.props.children;
|
||
}
|
||
if (!formValues.avatarColor) {
|
||
return "Standardfarbe";
|
||
}
|
||
return formValues.avatarColor;
|
||
}}
|
||
>
|
||
<Option value="">
|
||
<span className="flex items-center gap-2">
|
||
<span className="h-4 w-4 rounded border border-blue-gray-100" />
|
||
<span className="text-xs">Keine</span>
|
||
</span>
|
||
</Option>
|
||
{AVATAR_COLORS.map((color) => (
|
||
<Option key={color} value={color}>
|
||
<span className="flex items-center gap-2">
|
||
<span className={`h-4 w-4 rounded border border-blue-gray-100 ${color}`} />
|
||
<span className="text-xs">{color}</span>
|
||
</span>
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Typography variant="h6" color="blue-gray" className="mb-4">
|
||
Mitglieder
|
||
</Typography>
|
||
{!group || group.members.length === 0 ? (
|
||
<Typography className="text-sm text-stormGrey-500">
|
||
Aktuell sind keine Benutzer dieser Gruppe zugeordnet.
|
||
</Typography>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{group.members.map((member) => (
|
||
<Chip
|
||
key={`${group.id}-${member.id}-${member.username}`}
|
||
value={member.username}
|
||
size="sm"
|
||
color="blue-gray"
|
||
variant="ghost"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="mt-6 text-xs text-stormGrey-400">
|
||
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
|
||
</div>
|
||
</div>
|
||
<div className="lg:col-span-2 xl:col-span-3">
|
||
{(!maintenanceActive && !updateRunning) && (
|
||
<>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default UserGroupDetail;
|