Forgot Passaord

This commit is contained in:
root
2025-11-05 19:46:57 +00:00
parent 3140a024e6
commit 71ab82b592
31 changed files with 5193 additions and 1259 deletions
+494 -30
View File
@@ -10,11 +10,16 @@ import {
Select,
Option,
Input,
Chip
Chip,
Radio,
List,
ListItem,
ListItemPrefix
} from "@material-tailwind/react";
import { useToast } from "@/components/ToastProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useAuth } from "@/components/AuthProvider.jsx";
import { AVATAR_COLORS } from "@/data/avatarColors.js";
const _ = AVATAR_COLORS.join(" ");
@@ -53,34 +58,158 @@ const buildInitialFormValues = (group) => {
};
};
const LEVEL_OPTIONS = [
{ value: "full", label: "Vollzugriff" },
{ value: "read", label: "Nur lesen" },
{ value: "none", label: "Kein Zugriff" }
];
export function UserGroupDetail() {
const { groupId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const { hasPermission, user: authUser } = useAuth();
const [group, setGroup] = useState(null);
const [formValues, setFormValues] = useState(buildInitialFormValues(null));
const initialFormValuesRef = useRef(buildInitialFormValues(null));
const [initialPermissionSelection, setInitialPermissionSelection] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [hasLoaded, setHasLoaded] = useState(false);
const [savingGroup, setSavingGroup] = useState(false);
const [saveError, setSaveError] = useState("");
const [permissionSections, setPermissionSections] = useState([]);
const [permissionSelection, setPermissionSelection] = useState({});
const [permissionDefaults, setPermissionDefaults] = useState({});
const [permissionsLoading, setPermissionsLoading] = useState(false);
const [permissionsError, setPermissionsError] = useState("");
const maintenanceActive = Boolean(maintenance?.active);
const isSuperuserGroup = useMemo(() => (group?.name || "").toLowerCase() === "superuser", [group]);
const isSuperuserAccount = Boolean(authUser?.isSuperuser);
const canEditGroupDetails = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const canViewPermissionSettings = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "read"));
const canAdjustPermissions = Boolean(isSuperuserAccount || hasPermission("user-groups-edit", "full"));
const numericGroupId = useMemo(() => {
const candidate = Number(groupId);
return Number.isFinite(candidate) ? candidate : null;
}, [groupId]);
const fetchGroupPermissions = useCallback(
async (targetGroupId) => {
const numericTargetId = Number(targetGroupId);
if (!Number.isFinite(numericTargetId) || numericTargetId <= 0) {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
return;
}
setPermissionsLoading(true);
setPermissionsError("");
try {
const response = await axios.get(`/api/groups/${numericTargetId}/permissions`);
const rawSections = Array.isArray(response.data?.sections) ? response.data.sections : [];
const rawValues =
response.data?.values && typeof response.data.values === "object" ? response.data.values : {};
const defaults = {};
const initialSelection = {};
const normalizeItem = (item, index) => {
if (!item || typeof item !== "object") {
return null;
}
const defaultLevel =
typeof item.defaultLevel === "string" && item.defaultLevel ? item.defaultLevel : "none";
defaults[item.key] = defaultLevel;
const assigned = rawValues[item.key];
initialSelection[item.key] = typeof assigned === "string" && assigned ? assigned : defaultLevel;
const availableLevels =
Array.isArray(item.availableLevels) && item.availableLevels.length
? item.availableLevels
: LEVEL_OPTIONS.map((option) => option.value);
const dependencies = Array.isArray(item.dependencies) ? item.dependencies : [];
return {
...item,
availableLevels,
dependencies,
sortOrder: typeof item.sortOrder === "number" ? item.sortOrder : index
};
};
const normalizedSections = rawSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items)
? section.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
const sectionGroups = Array.isArray(section.groups)
? section.groups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items)
? group.items.map((item, itemIdx) => normalizeItem(item, itemIdx)).filter(Boolean)
: [];
return {
...group,
sortOrder: typeof group.sortOrder === "number" ? group.sortOrder : groupIdx,
items: groupItems
};
})
: [];
return {
...section,
sortOrder: typeof section.sortOrder === "number" ? section.sortOrder : sectionIndex,
hasNavigation: Boolean(section.hasNavigation),
items: sectionItems,
groups: sectionGroups
};
});
setPermissionSections(normalizedSections);
setPermissionDefaults(defaults);
setPermissionSelection({ ...initialSelection });
setInitialPermissionSelection({ ...initialSelection });
} catch (err) {
let message = "Die Berechtigungen konnten nicht geladen werden.";
if (err.response?.data?.error === "GROUP_NOT_FOUND") {
message = "Die angeforderte Benutzergruppe wurde nicht gefunden.";
}
setPermissionsError(message);
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setInitialPermissionSelection({});
showToast({
variant: "error",
title: "Fehler beim Laden",
description: message
});
} finally {
setPermissionsLoading(false);
}
},
[showToast]
);
const fetchGroupDetails = useCallback(async () => {
if (!numericGroupId) {
setError("Ungültige Gruppen-ID.");
setGroup(null);
setFormValues(buildInitialFormValues(null));
initialFormValuesRef.current = buildInitialFormValues(null);
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setHasLoaded(true);
return;
}
@@ -99,6 +228,25 @@ export function UserGroupDetail() {
initialFormValuesRef.current = { ...initialValues };
setFormValues(initialValues);
setSaveError("");
const superuserDetected = (item.name || "").toLowerCase() === "superuser";
if (superuserDetected) {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
} else if (canViewPermissionSettings) {
fetchGroupPermissions(item.id);
} else {
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setInitialPermissionSelection({});
setPermissionsLoading(false);
}
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Gruppendetails konnten nicht geladen werden.";
@@ -114,6 +262,11 @@ export function UserGroupDetail() {
setGroup(null);
initialFormValuesRef.current = buildInitialFormValues(null);
setFormValues(buildInitialFormValues(null));
setPermissionSections([]);
setPermissionSelection({});
setPermissionDefaults({});
setPermissionsError("");
setPermissionsLoading(false);
setError(message);
showToast({
variant: "error",
@@ -124,13 +277,13 @@ export function UserGroupDetail() {
setLoading(false);
setHasLoaded(true);
}
}, [numericGroupId, showToast]);
}, [numericGroupId, showToast, fetchGroupPermissions, canViewPermissionSettings]);
useEffect(() => {
fetchGroupDetails();
}, [fetchGroupDetails]);
const hasChanges = useMemo(() => {
const detailsChanged = useMemo(() => {
if (!hasLoaded || !group) {
return false;
}
@@ -156,6 +309,28 @@ export function UserGroupDetail() {
);
}, [formValues, hasLoaded, group]);
const permissionsChanged = useMemo(() => {
if (isSuperuserGroup) {
return false;
}
const initial = initialPermissionSelection || {};
const current = permissionSelection || {};
const allKeys = new Set([...Object.keys(initial), ...Object.keys(current)]);
for (const key of allKeys) {
const initialValue = initial[key] ?? null;
const currentValue = current[key] ?? null;
if (initialValue !== currentValue) {
return true;
}
}
return false;
}, [permissionSelection, initialPermissionSelection, isSuperuserGroup]);
const hasChanges = useMemo(
() => detailsChanged || permissionsChanged,
[detailsChanged, permissionsChanged]
);
const handleNameChange = useCallback((event) => {
const { value } = event.target;
setFormValues((prev) => ({
@@ -180,7 +355,21 @@ export function UserGroupDetail() {
}, []);
const handleSaveGroup = useCallback(async () => {
if (!group || !hasChanges) {
if (!group || (!detailsChanged && !permissionsChanged)) {
return;
}
if (permissionsChanged && isSuperuserGroup) {
return;
}
if (detailsChanged && !canEditGroupDetails) {
setSaveError("Dir fehlt die Berechtigung, diese Gruppe zu bearbeiten.");
return;
}
if (permissionsChanged && !canAdjustPermissions) {
setSaveError("Dir fehlt die Berechtigung, die Gruppenrechte anzupassen.");
return;
}
@@ -188,29 +377,51 @@ export function UserGroupDetail() {
setSaveError("");
try {
const payload = {
avatarColor: formValues.avatarColor
};
if (detailsChanged) {
const payload = {
avatarColor: formValues.avatarColor
};
if (!isSuperuserGroup) {
payload.name = formValues.name;
payload.description = formValues.description;
if (!isSuperuserGroup) {
payload.name = formValues.name;
payload.description = formValues.description;
}
const response = await axios.put(`/api/groups/${group.id}`, payload);
const updated = mapGroup(response.data?.item || response.data?.group);
if (updated?.id) {
setGroup(updated);
const nextInitial = buildInitialFormValues(updated);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
}
}
const response = await axios.put(`/api/groups/${group.id}`, payload);
const updated = mapGroup(response.data?.item || response.data?.group);
setGroup(updated);
const nextInitial = buildInitialFormValues(updated);
initialFormValuesRef.current = { ...nextInitial };
setFormValues(nextInitial);
showToast({
variant: "success",
title: "Gruppe gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
if (permissionsChanged && !isSuperuserGroup) {
await axios.put(`/api/groups/${group.id}/permissions`, {
values: permissionSelection
});
setInitialPermissionSelection({ ...permissionSelection });
}
if (detailsChanged) {
showToast({
variant: "success",
title: "Gruppe gespeichert",
description: "Die Änderungen wurden erfolgreich gespeichert."
});
} else if (permissionsChanged) {
showToast({
variant: "success",
title: "Berechtigungen gespeichert",
description: "Die Berechtigungen wurden erfolgreich gespeichert."
});
}
setSaveError("");
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Die Gruppe konnte nicht gespeichert werden.";
let message = "Die Änderungen konnten nicht gespeichert werden.";
if (serverError === "GROUP_NAME_REQUIRED") {
message = "Bitte einen Gruppennamen angeben.";
@@ -221,7 +432,13 @@ export function UserGroupDetail() {
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Die Benutzergruppe wurde nicht gefunden.";
} else if (serverError === "GROUP_SUPERUSER_PROTECTED") {
message = "Für die Superuser-Gruppe kann nur die Avatar-Farbe angepasst werden.";
message = "Für die Superuser-Gruppe können Berechtigungen nicht angepasst werden.";
} else if (serverError === "PERMISSION_INVALID_PAYLOAD") {
message = "Ungültige Berechtigungsdaten übermittelt.";
} else if (serverError === "PERMISSION_INVALID_LEVEL") {
message = "Für mindestens eine Berechtigung wurde ein nicht erlaubter Wert übermittelt.";
} else if (serverError === "PERMISSION_UNKNOWN_KEY") {
message = "Es wurde eine unbekannte Berechtigung übermittelt.";
}
setSaveError(message);
@@ -233,7 +450,17 @@ export function UserGroupDetail() {
} finally {
setSavingGroup(false);
}
}, [group, hasChanges, formValues, showToast, isSuperuserGroup]);
}, [
group,
detailsChanged,
permissionsChanged,
formValues,
showToast,
isSuperuserGroup,
permissionSelection,
canEditGroupDetails,
canAdjustPermissions
]);
const avatarLabel = useMemo(() => {
const source = (formValues.name || formValues.description || "").trim();
@@ -250,10 +477,168 @@ export function UserGroupDetail() {
return group?.avatarColor || "";
}, [formValues.avatarColor, group]);
const selectDisabled = maintenanceActive || savingGroup || !group;
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup;
const inputDisabled = maintenanceActive || savingGroup || !group || isSuperuserGroup || !canEditGroupDetails;
const selectDisabled = maintenanceActive || savingGroup || !group || !canEditGroupDetails;
const selectValue = formValues.avatarColor || "";
const handlePermissionChange = useCallback((permissionKey, value) => {
if (!permissionKey || !canAdjustPermissions || isSuperuserGroup) {
return;
}
setPermissionSelection((prev) => {
if (prev[permissionKey] === value) {
return prev;
}
return {
...prev,
[permissionKey]: value
};
});
}, [canAdjustPermissions, isSuperuserGroup]);
const getPermissionValue = useCallback(
(permissionKey) => {
if (!permissionKey) {
return null;
}
if (Object.prototype.hasOwnProperty.call(permissionSelection, permissionKey)) {
return permissionSelection[permissionKey];
}
if (Object.prototype.hasOwnProperty.call(permissionDefaults, permissionKey)) {
return permissionDefaults[permissionKey];
}
return null;
},
[permissionSelection, permissionDefaults]
);
const radioIcon = useMemo(
() => (
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-400 bg-black" />
),
[]
);
const radioCheckedIcon = useMemo(
() => (
<span className="mx-auto block h-2.5 w-2.5 rounded-full border border-blue-gray-700 bg-gray-900" />
),
[]
);
const isPermissionRowVisible = useCallback(
(row) => {
if (!row || !row.key) {
return false;
}
const dependencies = Array.isArray(row.dependencies) ? row.dependencies : [];
return dependencies.every((dependency) => {
if (!dependency || !dependency.key) {
return true;
}
const value = getPermissionValue(dependency.key) ?? "none";
const requirement = dependency.requiredLevel || "!=none";
if (requirement === "!=none") {
return value !== "none";
}
if (requirement.startsWith("!=")) {
return value !== requirement.substring(2);
}
if (requirement.startsWith("=")) {
return value === requirement.substring(1);
}
if (!requirement) {
return true;
}
return value === requirement;
});
},
[getPermissionValue]
);
const renderPermissionRow = (row, ownerKey, options = {}) => {
if (!isPermissionRowVisible(row)) {
return null;
}
const rowName = `permission-${ownerKey}-${row.key}`;
const currentValue = getPermissionValue(row.key) ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
"flex flex-col gap-2.5 px-4 py-4 md:flex-row md:items-center md:gap-6",
addTopBorder ? "border-t border-blue-gray-100" : ""
]
.filter(Boolean)
.join(" ");
const availableLevels = new Set(
Array.isArray(row.availableLevels) && row.availableLevels.length
? row.availableLevels
: LEVEL_OPTIONS.map((option) => option.value)
);
return (
<div key={`${ownerKey}-${row.key}`} className={rowClasses}>
<div className="text-sm font-medium leading-5 text-blue-gray-600 md:w-1/2">
{row.label}
</div>
<List className="flex-col gap-1.5 md:ml-auto md:flex-row md:flex-nowrap md:w-1/2 md:items-center md:justify-end md:gap-3">
{LEVEL_OPTIONS.map((option) => {
const optionId = `${rowName}-${option.value}`;
const checked = currentValue === option.value;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup;
return (
<ListItem
key={`${ownerKey}-${row.key}-${option.value}`}
className="w-full min-w-[140px] p-0 md:w-auto md:flex-1"
>
<label
htmlFor={optionId}
className={`inline-flex w-full items-center gap-2 rounded-lg px-3 py-2 min-h-[38px] ${
disabled ? "cursor-not-allowed bg-blue-gray-50/40" : "cursor-pointer hover:bg-blue-gray-50/60"
}`}
>
<ListItemPrefix className="flex-shrink-0">
<Radio
id={optionId}
name={rowName}
value={option.value}
checked={checked}
onChange={() => handlePermissionChange(row.key, option.value)}
disabled={disabled}
ripple={false}
className="h-4 w-4 hover:before:opacity-0"
containerProps={{
className: "p-0"
}}
icon={radioIcon}
checkedIcon={radioCheckedIcon}
/>
</ListItemPrefix>
<Typography
color="blue-gray"
className={`text-sm font-medium leading-5 whitespace-nowrap ${
disabled ? "text-blue-gray-300" : "text-blue-gray-600"
}`}
>
{option.label}
</Typography>
</label>
</ListItem>
);
})}
</List>
</div>
);
};
return (
<>
<div className="mt-12 flex flex-col gap-12">
@@ -415,11 +800,90 @@ export function UserGroupDetail() {
<p>Mitglieder insgesamt: {group?.memberCount ?? 0}</p>
</div>
</div>
<div className="lg:col-span-2 xl:col-span-3">
<Typography variant="h6" color="blue-gray" className="mb-4">
Berechtigungen (Ansicht)
</Typography>
{isSuperuserGroup ? (
<div className="rounded-xl border border-blue-gray-100 bg-white px-4 py-4 text-sm text-blue-gray-600 shadow-sm">
Die Superuser-Gruppe besitzt automatisch Vollzugriff auf alle Bereiche. Berechtigungen können hier nicht angepasst werden.
</div>
) : !canViewPermissionSettings ? null : (
<div className="rounded-xl border border-blue-gray-100 bg-white shadow-sm">
{permissionsLoading && (
<div className="border-b border-blue-gray-100 px-4 py-4">
<div className="flex items-center gap-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Berechtigungen werden geladen ...</span>
</div>
</div>
)}
{!permissionsLoading && permissionsError && (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-red-700">
{permissionsError}
</div>
)}
{!permissionsLoading && !permissionsError &&
(permissionSections.length === 0 ? (
<div className="border-b border-blue-gray-100 px-4 py-4 text-sm text-blue-gray-500">
Für diese Gruppe sind keine Berechtigungen definiert.
</div>
) : (
permissionSections.map((section, sectionIndex) => {
const sectionItems = Array.isArray(section.items) ? section.items : [];
const sectionGroups = Array.isArray(section.groups) ? section.groups : [];
return (
<div
key={section.key || sectionIndex}
className={sectionIndex === 0 ? "" : "border-t border-blue-gray-100"}
>
<div className="border-b border-blue-gray-100 px-4 py-4">
<Typography variant="h6" className="text-lg font-semibold text-blue-gray-700">
{section.title}
</Typography>
</div>
{sectionItems.map((row, rowIndex) =>
renderPermissionRow(row, section.key || sectionIndex, {
addTopBorder: rowIndex !== 0
})
)}
{sectionGroups.map((group, groupIdx) => {
const groupItems = Array.isArray(group.items) ? group.items : [];
const hasVisibleRows = groupItems.some(isPermissionRowVisible);
if (!hasVisibleRows) {
return null;
}
return (
<div key={group.key || `${section.key || sectionIndex}-${groupIdx}`} className="border-t border-blue-gray-100">
<div className="border-b border-blue-gray-100 px-4 py-3">
<Typography className="text-sm font-semibold uppercase tracking-wide text-blue-gray-600">
{group.title}
</Typography>
</div>
{groupItems.map((row, rowIndex) =>
renderPermissionRow(row, group.key || `${section.key || sectionIndex}-${groupIdx}`, {
addTopBorder: rowIndex !== 0
})
)}
</div>
);
})}
</div>
);
})
))}
</div>
)}
</div>
</div>
</CardBody>
</Card></div>
</>
);
</Card>
</div>
</>
);
}
export default UserGroupDetail;
export default UserGroupDetail;