This commit is contained in:
2025-12-01 07:59:40 +00:00
parent fc3ed57a40
commit f109423f54
39 changed files with 2914 additions and 2958 deletions
+84 -4
View File
@@ -23,10 +23,52 @@ const normalizePermissions = (raw) => {
}, {});
};
const normalizeServerPermissions = (raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return Object.entries(raw).reduce((acc, [serverId, value]) => {
const numericId = Number(serverId);
if (!Number.isFinite(numericId)) {
return acc;
}
const permissionMap = value?.permissions && typeof value.permissions === "object"
? value.permissions
: value;
acc[numericId] = {
permissions: normalizePermissions(permissionMap),
groupId: Number.isFinite(Number(value?.groupId)) ? Number(value.groupId) : null,
useGlobalGroup: Boolean(value?.useGlobalGroup)
};
return acc;
}, {});
};
const normalizeServerAssignments = (raw) => {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
const serverId = Number(entry?.serverId ?? entry?.server_id ?? entry?.id);
if (!Number.isFinite(serverId)) return null;
return {
serverId,
groupId: Number.isFinite(Number(entry?.groupId ?? entry?.group_id)) ? Number(entry?.groupId ?? entry?.group_id) : null,
useGlobalGroup: Boolean(entry?.useGlobalGroup ?? entry?.use_global_group),
serverName: entry?.serverName || entry?.server_name || null,
serverUrl: entry?.serverUrl || entry?.server_url || null
};
})
.filter(Boolean);
};
export function AuthProvider({ children }) {
const [state, setState] = useState({
user: null,
permissions: {},
serverPermissions: {},
serverAssignments: [],
loading: false,
error: null,
lastErrorCode: null,
@@ -36,9 +78,13 @@ export function AuthProvider({ children }) {
const setSession = useCallback((payload) => {
const user = payload?.user ?? null;
const permissions = normalizePermissions(payload?.permissions);
const serverPermissions = normalizeServerPermissions(payload?.serverPermissions);
const serverAssignments = normalizeServerAssignments(payload?.serverAssignments ?? user?.serverAssignments);
setState({
user,
permissions,
serverPermissions,
serverAssignments,
loading: false,
error: null,
lastErrorCode: null,
@@ -50,6 +96,8 @@ export function AuthProvider({ children }) {
setState((prev) => ({
user: null,
permissions: {},
serverPermissions: {},
serverAssignments: [],
loading: false,
error: null,
lastErrorCode: null,
@@ -80,6 +128,8 @@ export function AuthProvider({ children }) {
error: payload?.error ?? null,
lastErrorCode: payload?.error ?? null,
initialized: true,
serverPermissions: {},
serverAssignments: []
});
return { ok: false, status: response.status, error: payload?.error ?? null };
}
@@ -89,9 +139,11 @@ export function AuthProvider({ children }) {
} catch (err) {
const message = err?.message || "NETWORK_ERROR";
setState({
user: null,
permissions: {},
loading: false,
user: null,
permissions: {},
serverPermissions: {},
serverAssignments: [],
loading: false,
error: message,
lastErrorCode: "NETWORK_ERROR",
initialized: true,
@@ -140,10 +192,36 @@ export function AuthProvider({ children }) {
[state.permissions, state.user]
);
const hasServerPermission = useCallback(
(serverId, permissionKey, requiredLevel = "full") => {
if (!permissionKey) {
return true;
}
if (state.user?.isSuperuser) {
return true;
}
const numericServerId = Number(serverId);
let permissionMap = null;
if (Number.isFinite(numericServerId)) {
const entry = state.serverPermissions?.[numericServerId] || state.serverPermissions?.[String(numericServerId)];
if (entry?.permissions && typeof entry.permissions === "object") {
permissionMap = entry.permissions;
}
}
const basePermissions = permissionMap || state.permissions || {};
const current = basePermissions[permissionKey] ?? "none";
const currentPriority = LEVEL_PRIORITY[current] ?? 0;
const requiredPriority = LEVEL_PRIORITY[requiredLevel] ?? LEVEL_PRIORITY.full;
return currentPriority >= requiredPriority;
},
[state.serverPermissions, state.permissions, state.user]
);
const value = useMemo(
() => ({
user: state.user,
permissions: state.permissions,
serverPermissions: state.serverPermissions,
loading: state.loading,
error: state.error,
lastErrorCode: state.lastErrorCode,
@@ -154,8 +232,10 @@ export function AuthProvider({ children }) {
clearSession,
logout,
hasPermission,
hasServerPermission,
serverAssignments: state.serverAssignments,
}),
[state, refreshSession, setSession, clearSession, logout, hasPermission]
[state, refreshSession, setSession, clearSession, logout, hasPermission, hasServerPermission]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+425 -23
View File
@@ -14,7 +14,6 @@ import {
} 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";
@@ -69,6 +68,47 @@ const mapUser = (item) => ({
securityPhraseDownloadedAt: item?.securityPhraseDownloadedAt || null
});
const normalizeServer = (item) => {
if (!item || typeof item !== "object") {
return null;
}
const id = Number(item.id ?? item.serverId ?? item.server_id);
if (!Number.isFinite(id)) {
return null;
}
const name = item.name || item.serverName || item.server_name || `Server ${id}`;
return {
id,
name,
url: item.url || item.serverUrl || item.server_url || ""
};
};
const normalizeAssignmentsList = (list, servers = []) => {
const serverMap = new Map(servers.map((server) => [server.id, server]));
if (!Array.isArray(list)) {
return [];
}
return list
.map((entry) => {
const serverId = Number(entry?.serverId ?? entry?.server_id);
if (!Number.isFinite(serverId)) {
return null;
}
const groupId = Number(entry?.groupId ?? entry?.group_id);
const serverFallback = serverMap.get(serverId);
return {
serverId,
serverName: entry?.serverName || entry?.server_name || serverFallback?.name || "",
serverUrl: entry?.serverUrl || entry?.server_url || serverFallback?.url || "",
groupId: Number.isFinite(groupId) ? groupId : null,
groupName: entry?.groupName || entry?.group_name || "",
useGlobalGroup: Boolean(entry?.useGlobalGroup ?? entry?.use_global_group)
};
})
.filter(Boolean);
};
const extractPrimaryGroupId = (user) => {
if (!user || !Array.isArray(user.groups) || user.groups.length === 0) {
return null;
@@ -123,6 +163,11 @@ export function UserDetails() {
const [securityPhraseLoading, setSecurityPhraseLoading] = useState(false);
const [securityPhraseError, setSecurityPhraseError] = useState("");
const [renewingSecurityPhrase, setRenewingSecurityPhrase] = useState(false);
const [serverAssignments, setServerAssignments] = useState([]);
const [availableServers, setAvailableServers] = useState([]);
const [assignmentsError, setAssignmentsError] = useState("");
const [savingAssignments, setSavingAssignments] = useState(false);
const initialAssignmentsRef = useRef([]);
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
@@ -172,11 +217,22 @@ export function UserDetails() {
if (!item.id) {
throw new Error("USER_NOT_FOUND");
}
const normalizedServers = Array.isArray(response.data?.servers)
? response.data.servers.map(normalizeServer).filter(Boolean)
: [];
setAvailableServers(normalizedServers);
const normalizedAssignments = normalizeAssignmentsList(
response.data?.item?.serverAssignments ?? response.data?.serverAssignments ?? [],
normalizedServers
);
initialAssignmentsRef.current = normalizedAssignments;
setServerAssignments(normalizedAssignments);
setAssignmentsError("");
setUser(item);
setSecurityPhraseWords([]);
setSecurityPhraseDownloadedAt(item.securityPhraseDownloadedAt || null);
setSecurityPhraseError("");
const initialValues = buildInitialFormValues(item);
const initialValues = buildInitialFormValues(item);
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
@@ -195,6 +251,9 @@ export function UserDetails() {
setUser(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setServerAssignments([]);
setAvailableServers([]);
setAssignmentsError("");
setError(message);
showToast({
variant: "error",
@@ -341,6 +400,208 @@ export function UserDetails() {
}));
}, [canEditUsers]);
const assignmentsChanged = useMemo(() => {
const serialize = (list) =>
JSON.stringify(
list
.map((entry) => ({
serverId: Number(entry.serverId) || null,
groupId: entry.useGlobalGroup ? null : (Number(entry.groupId) || null),
useGlobalGroup: Boolean(entry.useGlobalGroup)
}))
.sort((a, b) => (a.serverId || 0) - (b.serverId || 0))
);
return serialize(serverAssignments) !== serialize(initialAssignmentsRef.current);
}, [serverAssignments]);
const usedServerIds = useMemo(
() =>
new Set(
serverAssignments
.map((entry) => Number(entry.serverId))
.filter((value) => Number.isFinite(value) && value > 0)
),
[serverAssignments]
);
const getServerOptionsForRow = useCallback(
(currentServerId) => {
return availableServers.filter(
(server) => server.id === currentServerId || !usedServerIds.has(server.id)
);
},
[availableServers, usedServerIds]
);
const handleAddAssignment = useCallback(() => {
if (!canEditUsers || isSuperuserUser) return;
const usedServerIds = new Set(
serverAssignments
.map((entry) => Number(entry.serverId))
.filter((value) => Number.isFinite(value) && value > 0)
);
const nextServer = availableServers.find((server) => !usedServerIds.has(server.id));
if (!nextServer) {
setAssignmentsError("Alle verfügbaren Server sind bereits zugeordnet.");
return;
}
setAssignmentsError("");
setServerAssignments((prev) => [
...prev,
{
serverId: nextServer.id,
serverName: nextServer.name ?? "",
serverUrl: nextServer.url ?? "",
groupId: null,
groupName: "",
useGlobalGroup: true
}
]);
}, [canEditUsers, serverAssignments, availableServers]);
const handleAssignmentServerChange = useCallback(
(index, value) => {
if (!canEditUsers || isSuperuserUser) return;
const numeric = Number(value);
const server = availableServers.find((item) => item.id === numeric);
setServerAssignments((prev) =>
prev.map((entry, idx) =>
idx === index
? {
...entry,
serverId: Number.isFinite(numeric) ? numeric : null,
serverName: server?.name || entry.serverName,
serverUrl: server?.url || entry.serverUrl
}
: entry
)
);
},
[availableServers, canEditUsers]
);
const handleAssignmentGroupChange = useCallback(
(index, value) => {
if (!canEditUsers || isSuperuserUser) return;
const numeric = Number(value);
setServerAssignments((prev) =>
prev.map((entry, idx) =>
idx === index
? {
...entry,
groupId: Number.isFinite(numeric) ? numeric : null,
useGlobalGroup: false
}
: entry
)
);
},
[canEditUsers]
);
const handleAssignmentUseGlobalToggle = useCallback(
(index, checked) => {
if (!canEditUsers || isSuperuserUser) return;
setServerAssignments((prev) =>
prev.map((entry, idx) =>
idx === index
? {
...entry,
useGlobalGroup: Boolean(checked),
groupId: checked ? null : entry.groupId
}
: entry
)
);
},
[canEditUsers]
);
const handleRemoveAssignment = useCallback(
(index) => {
if (!canEditUsers || isSuperuserUser) return;
setServerAssignments((prev) => prev.filter((_, idx) => idx !== index));
},
[canEditUsers, isSuperuserUser]
);
const handleSaveAssignments = useCallback(async () => {
if (!canEditUsers || !numericUserId || !assignmentsChanged || isSuperuserUser) {
return;
}
const normalizedPayload = [];
const seenServers = new Set();
for (const assignment of serverAssignments) {
const serverId = Number(assignment.serverId);
if (!Number.isFinite(serverId) || serverId <= 0) {
setAssignmentsError("Bitte wähle für jede Zuordnung einen Server aus.");
return;
}
if (seenServers.has(serverId)) {
setAssignmentsError("Jeder Server kann nur einmal zugeordnet werden.");
return;
}
seenServers.add(serverId);
const useGlobalGroup = Boolean(assignment.useGlobalGroup);
const groupId = Number(assignment.groupId);
if (!useGlobalGroup && (!Number.isFinite(groupId) || groupId <= 0)) {
setAssignmentsError("Bitte wähle eine Rechtegruppe oder aktiviere die globale Gruppe.");
return;
}
normalizedPayload.push({
serverId,
groupId: useGlobalGroup ? null : groupId,
useGlobalGroup
});
}
setAssignmentsError("");
setSavingAssignments(true);
try {
const response = await axios.put(
`/api/users/${numericUserId}/server-assignments`,
{ assignments: normalizedPayload }
);
const normalizedAssignments = normalizeAssignmentsList(
response.data?.items ?? [],
availableServers
);
initialAssignmentsRef.current = normalizedAssignments;
setServerAssignments(normalizedAssignments);
showToast({
variant: "success",
title: "Serverzuordnungen gespeichert",
description: "Die Zuordnungen wurden erfolgreich aktualisiert."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Die Serverzuordnungen konnten nicht gespeichert 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 === "USER_SUPERUSER_PROTECTED") {
message = "Für den Superuser können keine Server-Zuordnungen gesetzt werden.";
} else if (serverError === "SERVER_NOT_FOUND") {
message = "Ein ausgewählter Server existiert nicht mehr.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Eine ausgewählte Rechtegruppe existiert nicht mehr.";
} else if (serverError === "INSUFFICIENT_PERMISSIONS") {
message = "Keine Berechtigung zum Ändern der Zuordnungen.";
}
setAssignmentsError(message);
showToast({
variant: "error",
title: "Speichern fehlgeschlagen",
description: message
});
} finally {
setSavingAssignments(false);
}
}, [canEditUsers, numericUserId, serverAssignments, availableServers, assignmentsChanged, showToast]);
const handleSaveUser = useCallback(async () => {
if (!canEditUsers || !user || !hasChanges) {
return;
@@ -775,30 +1036,171 @@ export function UserDetails() {
</div>
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
Server-Zuordnungen
</Typography>
<div className="flex flex-col gap-12">
{platformSettingsData.map(({ title, options }) => (
<div key={title}>
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
{title}
</Typography>
<div className="flex flex-col gap-6">
{options.map(({ checked, label }) => (
<Switch
key={label}
id={label}
label={label}
defaultChecked={checked}
labelProps={{
className: "text-sm font-normal text-blue-gray-500",
}}
/>
))}
{isSuperuserUser ? (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Für den Superuser gelten immer alle Rechte und es können keine server-spezifischen Zuordnungen gesetzt werden.
</div>
) : (
<>
<Typography className="mb-4 text-sm text-blue-gray-500">
Ohne Zuordnungen hat der Benutzer Zugriff auf alle Server mit seinen globalen Rechten.
Lege hier Server-spezifische Gruppen fest oder markiere den Server explizit zur Nutzung der globalen Gruppe.
</Typography>
{assignmentsError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{assignmentsError}
</div>
)}
<div className="flex flex-col gap-4">
{serverAssignments.length === 0 ? (
<div className="rounded-lg border border-blue-gray-100 bg-blue-gray-50/60 px-4 py-3 text-sm text-blue-gray-600">
Keine Server zugeordnet. Die globalen Gruppen gelten für alle Server.
</div>
) : (
serverAssignments.map((assignment, index) => {
const serverOptions = getServerOptionsForRow(assignment.serverId);
const groupSelectValue =
assignment.useGlobalGroup || !assignment.groupId
? ""
: String(assignment.groupId);
return (
<div
key={`assignment-${assignment.serverId ?? index}-${index}`}
className="rounded-lg border border-blue-gray-100 bg-white px-4 py-3 shadow-sm"
>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Server
</Typography>
<Select
label="Server auswählen"
value={
assignment.serverId
? String(assignment.serverId)
: ""
}
onChange={(value) =>
handleAssignmentServerChange(index, value)
}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers
}
variant="outlined"
>
{serverOptions.map((server) => (
<Option key={server.id} value={String(server.id)}>
{server.name}
</Option>
))}
</Select>
</div>
<div>
<Typography className="mb-2 block text-xs font-semibold uppercase text-blue-gray-500">
Rechtegruppe
</Typography>
<div className="flex flex-col gap-2">
<Switch
id={`assignment-global-${index}`}
label="Globale Gruppe verwenden"
checked={assignment.useGlobalGroup}
onChange={({ target }) =>
handleAssignmentUseGlobalToggle(
index,
target?.checked
)
}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers
}
labelProps={{
className: "text-sm font-normal text-blue-gray-600"
}}
/>
<Select
label="Server-Gruppe wählen"
value={groupSelectValue}
onChange={(value) =>
handleAssignmentGroupChange(index, value)
}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers ||
assignment.useGlobalGroup
}
variant="outlined"
className="max-w-xl"
>
<Option value="">Globale Gruppe</Option>
{availableGroups.map((group) => (
<Option key={group.id} value={String(group.id)}>
{group.name}
</Option>
))}
</Select>
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="text"
color="red"
className="normal-case"
onClick={() => handleRemoveAssignment(index)}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers
}
>
Entfernen
</Button>
</div>
</div>
);
})
)}
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Button
variant="outlined"
color="blue"
className="normal-case"
onClick={handleAddAssignment}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers ||
availableServers.length === 0 ||
usedServerIds.size >= availableServers.length
}
>
Server hinzufügen
</Button>
<Button
color="green"
className="normal-case"
onClick={handleSaveAssignments}
disabled={
maintenanceLocked ||
savingAssignments ||
!canEditUsers ||
!assignmentsChanged
}
>
{savingAssignments ? "Speichert ..." : "Zuordnungen speichern"}
</Button>
{savingAssignments && <Spinner className="h-4 w-4" />}
</div>
</>
)}
</div>
</div>
+187 -97
View File
@@ -78,8 +78,14 @@ const LEVEL_PRIORITY = {
full: 2
};
const normalizePermissionLevel = (key, value) =>
key === "maintenance-server-delete" && value !== "full" ? "none" : value;
const isDependencySatisfied = (value, requirement) => {
const req = requirement || "!=none";
if (req === "!=none") return value !== "none";
if (req.startsWith("!=")) return value !== req.substring(2);
if (req.startsWith("=")) return value === req.substring(1);
if (!req) return true;
return value === req;
};
export function UserGroupDetail() {
const { groupId } = useParams();
@@ -177,22 +183,28 @@ export function UserGroupDetail() {
};
};
const normalizeGroup = (group, groupIdx = 0) => {
const groupItems = Array.isArray(group.items)
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
const childGroups = Array.isArray(group.groups)
? group.groups.map((child, childIdx) => normalizeGroup(child, childIdx))
: [];
return {
...group,
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
items: groupItems,
groups: childGroups
};
};
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
};
})
? section.groups.map((group, groupIdx) => normalizeGroup(group, groupIdx))
: [];
return {
@@ -515,16 +527,52 @@ export function UserGroupDetail() {
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
return;
}
const nextValue = normalizePermissionLevel(permissionKey, value);
setPermissionSelection((prev) => {
const shouldResetDelete =
permissionKey === "maintenance-server-manage" && nextValue !== "full";
const shouldResetEdit =
permissionKey === "maintenance-server-manage" && nextValue === "none";
const shouldResetDeleteFromEdit =
permissionKey === "maintenance-server-edit" && nextValue !== "full";
const getValueWithDefaults = (key, selectionMap) =>
selectionMap[key] ?? permissionDefaults[key] ?? "none";
if (!shouldResetDelete && !shouldResetEdit && !shouldResetDeleteFromEdit && prev[permissionKey] === nextValue) {
const collectAllItems = () => {
const items = [];
const walkGroups = (groups) => {
(groups || []).forEach((group) => {
(group.items || []).forEach((item) => items.push(item));
if (group.groups) {
walkGroups(group.groups);
}
});
};
permissionSections.forEach((section) => {
(section.items || []).forEach((item) => items.push(item));
walkGroups(section.groups);
});
return items;
};
const allItems = collectAllItems();
const itemMap = new Map(allItems.map((item) => [item.key, item]));
const clampToAllowed = (itemKey, desiredLevel) => {
const item = itemMap.get(itemKey);
if (!item) return desiredLevel;
const allowed = Array.isArray(item.availableLevels) && item.availableLevels.length
? item.availableLevels
: LEVEL_OPTIONS.map((opt) => opt.value);
if (allowed.includes(desiredLevel)) {
return desiredLevel;
}
// Fallback: wähle niedrigstes erlaubtes Level
return allowed.reduce((lowest, candidate) => {
return LEVEL_PRIORITY[candidate] < LEVEL_PRIORITY[lowest] ? candidate : lowest;
}, allowed[0] || "none");
};
const nextValue = clampToAllowed(permissionKey, value);
setPermissionSelection((prev) => {
const currentValue = getValueWithDefaults(permissionKey, prev);
const isDowngrade = LEVEL_PRIORITY[nextValue] < LEVEL_PRIORITY[currentValue];
if (prev[permissionKey] === nextValue) {
return prev;
}
@@ -533,19 +581,31 @@ export function UserGroupDetail() {
[permissionKey]: nextValue
};
if (shouldResetDelete) {
nextState["maintenance-server-delete"] = "none";
}
if (shouldResetEdit) {
nextState["maintenance-server-edit"] = "none";
}
if (shouldResetDeleteFromEdit) {
nextState["maintenance-server-delete"] = "none";
// Kaskade: Nur bei Downgrade des auslösenden Rechts abhängige Rechte herabstufen.
if (isDowngrade) {
let changed = true;
while (changed) {
changed = false;
allItems.forEach((item) => {
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
if (!dependencies.length) return;
const satisfied = dependencies.every((dep) =>
isDependencySatisfied(getValueWithDefaults(dep.key, nextState), dep.requiredLevel)
);
if (!satisfied) {
const cascadedValue = clampToAllowed(item.key, nextValue);
if (getValueWithDefaults(item.key, nextState) !== cascadedValue) {
nextState[item.key] = cascadedValue;
changed = true;
}
}
});
}
}
return nextState;
});
}, [canAdjustPermissions, isSuperuserGroup]);
}, [canAdjustPermissions, isSuperuserGroup, permissionDefaults, permissionSections]);
const getPermissionValue = useCallback(
(permissionKey) => {
@@ -559,7 +619,7 @@ export function UserGroupDetail() {
value = permissionDefaults[permissionKey];
}
if (typeof value === "string") {
return normalizePermissionLevel(permissionKey, value);
return value;
}
return value;
},
@@ -572,12 +632,6 @@ export function UserGroupDetail() {
),
[]
);
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) => {
@@ -617,9 +671,7 @@ export function UserGroupDetail() {
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 serverEditLevel = getPermissionValue("maintenance-server-edit") ?? "none";
const displayValue = currentValue;
const { addTopBorder = true } = options;
const rowClasses = [
@@ -632,11 +684,7 @@ export function UserGroupDetail() {
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 availableLevels = new Set(baseLevels);
const levelOptions = LEVEL_OPTIONS;
return (
@@ -648,22 +696,12 @@ export function UserGroupDetail() {
{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 requiresEditFull =
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverEditLevel] ?? 0) < LEVEL_PRIORITY.full;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup ||
requiresManageFull ||
requiresEditFull;
isSuperuserGroup;
return (
<ListItem
@@ -689,7 +727,6 @@ export function UserGroupDetail() {
className: "p-0"
}}
icon={radioIcon}
checkedIcon={radioCheckedIcon}
/>
</ListItemPrefix>
<Typography
@@ -708,6 +745,30 @@ export function UserGroupDetail() {
);
};
const groupHasVisibleRows = (grp) => {
if (!grp) {
return false;
}
const groupItems = Array.isArray(grp.items) ? grp.items : [];
const childGroups = Array.isArray(grp.groups) ? grp.groups : [];
if (groupItems.some(isPermissionRowVisible)) {
return true;
}
return childGroups.some(groupHasVisibleRows);
};
const sectionHasVisibleRows = (section) => {
if (!section) {
return false;
}
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
if (sectionItems.some(isPermissionRowVisible)) {
return true;
}
return sectionGroups.some(groupHasVisibleRows);
};
return (
<>
<div className="mt-12 flex flex-col gap-12">
@@ -725,9 +786,6 @@ export function UserGroupDetail() {
</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">
@@ -893,7 +951,7 @@ export function UserGroupDetail() {
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">
@@ -913,53 +971,85 @@ export function UserGroupDetail() {
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 : [];
(() => {
const renderedSections = 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 (!sectionHasVisibleRows(section)) {
return null;
}
if (!hasVisibleRows) {
const renderGroup = (currentGroup, path = `${section.key || sectionIndex}-group`, depth = 0) => {
if (!groupHasVisibleRows(currentGroup)) {
return null;
}
const groupItems = Array.isArray(currentGroup.items) ? currentGroup.items : [];
const childGroups = Array.isArray(currentGroup.groups) ? currentGroup.groups : [];
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}
<div
key={currentGroup.key || path}
className=""
>
<div className="border-b border-blue-gray-100 bg-blue-gray-50/80 px-4 py-3">
<Typography className={`${depth > 0 ? "ml-2" : ""} text-xs font-semibold uppercase tracking-wide text-blue-gray-600`}>
{currentGroup.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
<div className="divide-y divide-blue-gray-100">
{groupItems.map((row) =>
renderPermissionRow(row, `${path}-${row.key}`, {
addTopBorder: false
})
)}
{childGroups.map((childGroup, childIdx) =>
renderGroup(childGroup, `${path}-${childGroup.key || childIdx}`, depth + 1)
)}
</div>
</div>
);
})}
</div>
);
})
};
return (
<div
key={section.key || sectionIndex}
className="overflow-hidden rounded-xl border border-blue-gray-100 bg-white shadow-sm"
>
<div className="border-b border-blue-gray-100 bg-blue-gray-50/70 px-4 py-3">
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
{section.title}
</Typography>
</div>
<div className="divide-y divide-blue-gray-100">
{sectionItems.map((row) =>
renderPermissionRow(row, section.key || sectionIndex, {
addTopBorder: false
})
)}
{sectionGroups.map((group, groupIdx) =>
renderGroup(group, `${section.key || sectionIndex}-${groupIdx}`, 0)
)}
</div>
</div>
);
})
.filter(Boolean);
if (renderedSections.length === 0) {
return (
<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>
);
}
return <div className="flex flex-col gap-4">{renderedSections}</div>;
})()
))}
</div>
</>
)}
</>