diff --git a/backend/src/database/migrations/045_add_permissions_batch.sql b/backend/src/database/migrations/045_add_permissions_batch.sql new file mode 100644 index 0000000..94c4186 --- /dev/null +++ b/backend/src/database/migrations/045_add_permissions_batch.sql @@ -0,0 +1,170 @@ +-- Migration 045: Add new permissions + seed dependency config +-- 1. fahrzeuge:edit +-- 2. atemschutz:edit +-- 3. Per-tool admin permissions +-- 4. bestellungen:manage_orders + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 1. New permissions +-- ═══════════════════════════════════════════════════════════════════════════ + +-- fahrzeuge:edit (between create and change_status) +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('fahrzeuge:edit', 'fahrzeuge', 'Bearbeiten', 'Fahrzeugdaten bearbeiten', 2) +ON CONFLICT (id) DO NOTHING; + +-- atemschutz:edit (between view and create) +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('atemschutz:edit', 'atemschutz', 'Bearbeiten', 'Atemschutz-Einträge bearbeiten', 2) +ON CONFLICT (id) DO NOTHING; + +-- bestellungen:manage_orders (manage order status + received quantities) +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('bestellungen:manage_orders', 'bestellungen', 'Bestellungen verwalten', 'Bestellstatus ändern und Wareneingänge verbuchen', 3) +ON CONFLICT (id) DO NOTHING; + +-- Per-tool admin permissions +INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES + ('admin:view_services', 'admin', 'Services ansehen', 'Service-Monitoring einsehen', 3), + ('admin:edit_services', 'admin', 'Services bearbeiten', 'Überwachte Services verwalten', 4), + ('admin:view_system', 'admin', 'System ansehen', 'Systeminformationen einsehen', 5), + ('admin:view_users', 'admin', 'Benutzer ansehen', 'Benutzerliste einsehen', 6), + ('admin:edit_users', 'admin', 'Benutzer bearbeiten', 'Benutzereinstellungen verwalten', 7), + ('admin:edit_broadcast', 'admin', 'Broadcast senden', 'Push-Benachrichtigungen senden', 8), + ('admin:view_banner', 'admin', 'Banner ansehen', 'Ankündigungs-Banner einsehen', 9), + ('admin:edit_banner', 'admin', 'Banner bearbeiten', 'Ankündigungs-Banner verwalten', 10), + ('admin:view_maintenance', 'admin', 'Wartung ansehen', 'Wartungsmodus-Status einsehen', 11), + ('admin:edit_maintenance', 'admin', 'Wartung bearbeiten', 'Wartungsmodus umschalten', 12), + ('admin:view_fdisk', 'admin', 'FDISK ansehen', 'FDISK-Synchronisation einsehen', 13), + ('admin:edit_fdisk', 'admin', 'FDISK bearbeiten', 'FDISK-Synchronisation starten/verwalten', 14), + ('admin:view_permissions', 'admin', 'Berechtigungen ansehen', 'Berechtigungsmatrix einsehen', 15), + ('admin:edit_permissions', 'admin', 'Berechtigungen bearbeiten','Berechtigungen und Abhängigkeiten ändern',16), + ('admin:view_order_settings', 'admin', 'Bestell-Admin ansehen', 'Bestellungs-Admin-Einstellungen einsehen',17), + ('admin:edit_order_settings', 'admin', 'Bestell-Admin bearbeiten','Bestellungs-Admin-Einstellungen ändern', 18), + ('admin:view_data', 'admin', 'Datenverwaltung ansehen', 'Datenverwaltung einsehen', 19), + ('admin:edit_data', 'admin', 'Datenverwaltung bearbeiten','Daten importieren/exportieren/bereinigen',20), + ('admin:view_debug', 'admin', 'Debug ansehen', 'Debug-Informationen einsehen', 21) +ON CONFLICT (id) DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 2. Seed grants for existing groups +-- ═══════════════════════════════════════════════════════════════════════════ + +-- Give fahrzeuge:edit to groups that already have fahrzeuge:create +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT authentik_group, 'fahrzeuge:edit' +FROM group_permissions WHERE permission_id = 'fahrzeuge:create' +ON CONFLICT DO NOTHING; + +-- Give atemschutz:edit to groups that already have atemschutz:create +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT authentik_group, 'atemschutz:edit' +FROM group_permissions WHERE permission_id = 'atemschutz:create' +ON CONFLICT DO NOTHING; + +-- Give bestellungen:manage_orders to groups that already have bestellungen:create +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT authentik_group, 'bestellungen:manage_orders' +FROM group_permissions WHERE permission_id = 'bestellungen:create' +ON CONFLICT DO NOTHING; + +-- Give all admin sub-permissions to groups that have admin:write +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT gp.authentik_group, p.id +FROM group_permissions gp +CROSS JOIN permissions p +WHERE gp.permission_id = 'admin:write' + AND p.feature_group_id = 'admin' + AND p.id NOT IN ('admin:view', 'admin:write') +ON CONFLICT DO NOTHING; + +-- Give all admin view sub-permissions to groups that have admin:view but not admin:write +INSERT INTO group_permissions (authentik_group, permission_id) +SELECT gp.authentik_group, p.id +FROM group_permissions gp +CROSS JOIN permissions p +WHERE gp.permission_id = 'admin:view' + AND p.feature_group_id = 'admin' + AND p.id LIKE 'admin:view_%' +ON CONFLICT DO NOTHING; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- 3. Seed default permission dependency config +-- ═══════════════════════════════════════════════════════════════════════════ +-- Only insert if no config exists yet (don't overwrite manual edits) + +INSERT INTO app_settings (key, value) +SELECT 'permission_deps', '{ + "kalender:create": ["kalender:view"], + "kalender:view_bookings": ["kalender:view"], + "kalender:manage_bookings": ["kalender:view_bookings", "kalender:view"], + + "fahrzeuge:edit": ["fahrzeuge:view"], + "fahrzeuge:create": ["fahrzeuge:view"], + "fahrzeuge:change_status": ["fahrzeuge:view"], + "fahrzeuge:manage_maintenance": ["fahrzeuge:view"], + "fahrzeuge:delete": ["fahrzeuge:create", "fahrzeuge:view"], + "fahrzeuge:widget": ["fahrzeuge:view"], + + "einsaetze:view_reports": ["einsaetze:view"], + "einsaetze:create": ["einsaetze:view"], + "einsaetze:delete": ["einsaetze:create", "einsaetze:view"], + "einsaetze:manage_personnel": ["einsaetze:view"], + + "ausruestung:create": ["ausruestung:view"], + "ausruestung:manage_maintenance": ["ausruestung:view"], + "ausruestung:delete": ["ausruestung:create", "ausruestung:view"], + "ausruestung:manage_categories": ["ausruestung:view"], + "ausruestung:widget": ["ausruestung:view"], + + "mitglieder:view_all": ["mitglieder:view_own"], + "mitglieder:edit": ["mitglieder:view_all", "mitglieder:view_own"], + "mitglieder:create_profile": ["mitglieder:edit", "mitglieder:view_all", "mitglieder:view_own"], + + "atemschutz:edit": ["atemschutz:view"], + "atemschutz:create": ["atemschutz:view"], + "atemschutz:delete": ["atemschutz:create", "atemschutz:view"], + "atemschutz:widget": ["atemschutz:view"], + + "bestellungen:create": ["bestellungen:view"], + "bestellungen:manage_orders": ["bestellungen:view"], + "bestellungen:delete": ["bestellungen:view"], + "bestellungen:manage_vendors": ["bestellungen:view"], + "bestellungen:export": ["bestellungen:view"], + "bestellungen:manage_reminders": ["bestellungen:view"], + "bestellungen:widget": ["bestellungen:view"], + + "shop:create_request": ["shop:view"], + "shop:manage_catalog": ["shop:view"], + "shop:approve_requests": ["shop:view"], + "shop:link_orders": ["shop:approve_requests", "shop:view"], + "shop:view_overview": ["shop:view"], + "shop:order_for_user": ["shop:create_request", "shop:view"], + "shop:widget": ["shop:view"], + + "issues:view_own": ["issues:create"], + "issues:view_all": ["issues:view_own"], + "issues:manage": ["issues:view_all", "issues:view_own"], + + "admin:edit_services": ["admin:view_services", "admin:view"], + "admin:view_services": ["admin:view"], + "admin:view_system": ["admin:view"], + "admin:edit_users": ["admin:view_users", "admin:view"], + "admin:view_users": ["admin:view"], + "admin:edit_broadcast": ["admin:view"], + "admin:edit_banner": ["admin:view_banner", "admin:view"], + "admin:view_banner": ["admin:view"], + "admin:edit_maintenance": ["admin:view_maintenance", "admin:view"], + "admin:view_maintenance": ["admin:view"], + "admin:edit_fdisk": ["admin:view_fdisk", "admin:view"], + "admin:view_fdisk": ["admin:view"], + "admin:edit_permissions": ["admin:view_permissions", "admin:view"], + "admin:view_permissions": ["admin:view"], + "admin:edit_order_settings": ["admin:view_order_settings", "admin:view"], + "admin:view_order_settings": ["admin:view"], + "admin:edit_data": ["admin:view_data", "admin:view"], + "admin:view_data": ["admin:view"], + "admin:view_debug": ["admin:view"] +}'::jsonb +WHERE NOT EXISTS (SELECT 1 FROM app_settings WHERE key = 'permission_deps'); + diff --git a/frontend/src/components/admin/PermissionMatrixTab.tsx b/frontend/src/components/admin/PermissionMatrixTab.tsx index 9746d4d..ef542eb 100644 --- a/frontend/src/components/admin/PermissionMatrixTab.tsx +++ b/frontend/src/components/admin/PermissionMatrixTab.tsx @@ -94,16 +94,30 @@ const PERMISSION_SUB_GROUPS: Record> = { 'Buchungen': ['view_bookings', 'manage_bookings'], }, bestellungen: { - 'Bestellungen': ['view', 'create', 'delete', 'export'], + 'Bestellungen': ['view', 'create', 'manage_orders', 'delete', 'export'], 'Lieferanten': ['manage_vendors'], 'Erinnerungen': ['manage_reminders'], 'Widget': ['widget'], }, shop: { 'Katalog': ['view', 'manage_catalog'], - 'Anfragen': ['create_request', 'approve_requests', 'link_orders'], + 'Anfragen': ['create_request', 'approve_requests', 'link_orders', 'view_overview', 'order_for_user'], 'Widget': ['widget'], }, + admin: { + 'Allgemein': ['view', 'write'], + 'Services': ['view_services', 'edit_services'], + 'System': ['view_system'], + 'Benutzer': ['view_users', 'edit_users'], + 'Broadcast': ['edit_broadcast'], + 'Banner': ['view_banner', 'edit_banner'], + 'Wartung': ['view_maintenance', 'edit_maintenance'], + 'FDISK': ['view_fdisk', 'edit_fdisk'], + 'Berechtigungen': ['view_permissions', 'edit_permissions'], + 'Bestell-Admin': ['view_order_settings', 'edit_order_settings'], + 'Datenverwaltung': ['view_data', 'edit_data'], + 'Debug': ['view_debug'], + }, }; function getSubGroupLabel(featureGroupId: string, permId: string): string | null { @@ -327,6 +341,7 @@ function PermissionMatrixTab() { permissionDeps={permissionDeps} allGroups={nonAdminGroups} allPermissions={permissions} + featureGroups={featureGroups} onSave={(config) => depConfigMutation.mutate(config)} isSaving={depConfigMutation.isPending} /> @@ -512,14 +527,40 @@ interface DependencyEditorProps { permissionDeps: Record; allGroups: string[]; allPermissions: Permission[]; + featureGroups: FeatureGroup[]; onSave: (config: { groupHierarchy?: Record; permissionDeps?: Record }) => void; isSaving: boolean; } -function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermissions, onSave, isSaving }: DependencyEditorProps) { +function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermissions, featureGroups, onSave, isSaving }: DependencyEditorProps) { const groupSet = useMemo(() => new Set(allGroups), [allGroups]); const permIdSet = useMemo(() => new Set(allPermissions.map(p => p.id)), [allPermissions]); + // Build lookup: permId → feature_group_id + const permToFeatureGroup = useMemo(() => { + const map: Record = {}; + for (const p of allPermissions) map[p.id] = p.feature_group_id; + return map; + }, [allPermissions]); + + // Build lookup: feature_group_id → Permission[] + const permsByFeatureGroup = useMemo(() => { + const map: Record = {}; + for (const fg of featureGroups) map[fg.id] = []; + for (const p of allPermissions) { + if (!map[p.feature_group_id]) map[p.feature_group_id] = []; + map[p.feature_group_id].push(p); + } + return map; + }, [allPermissions, featureGroups]); + + // Format label as "feature:action (Label)" + const formatPermLabel = useCallback((permId: string) => { + const perm = allPermissions.find(p => p.id === permId); + if (!perm) return permId; + return `${permId} (${perm.label})`; + }, [allPermissions]); + // Filter saved config to only include groups/permissions that actually exist const [editHierarchy, setEditHierarchy] = useState>(() => { const filtered: Record = {}; @@ -541,7 +582,6 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis }); const nonAdminGroups = allGroups.filter(g => g !== 'dashboard_admin'); - const permOptions = allPermissions.map(p => p.id); const handleHierarchyChange = (group: string, inheritors: string[]) => { setEditHierarchy(prev => { @@ -577,6 +617,9 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis const [newDepPerm, setNewDepPerm] = useState(null); + // All permission options for "add new dependency" picker + const allPermOptions = allPermissions.map(p => p.id).filter(p => !editDeps[p]); + return ( {/* Group Hierarchy Editor */} @@ -610,39 +653,53 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis ))} - {/* Permission Dependency Editor */} + {/* Permission Dependency Editor — Grouped by Feature Group */} Berechtigungsabhängigkeiten Wenn eine Berechtigung gesetzt wird, werden die hier definierten Voraussetzungen automatisch mit aktiviert. + Abhängigkeiten können nur innerhalb derselben Feature-Gruppe gesetzt werden. - {Object.entries(editDeps).map(([permId, deps]) => { - const perm = allPermissions.find(p => p.id === permId); + + {featureGroups.map(fg => { + // Get dependencies that belong to this feature group + const fgDeps = Object.entries(editDeps).filter(([permId]) => permToFeatureGroup[permId] === fg.id); + if (fgDeps.length === 0) return null; + + // Options for dependency targets: only perms within same feature group + const sameGroupPermOptions = (permsByFeatureGroup[fg.id] || []).map(p => p.id); + return ( - - - {perm?.label ?? permId} + + + {fg.label} - benötigt - p !== permId)} - getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} - value={deps} - onChange={(_e, val) => handleDepChange(permId, val)} - renderInput={(params) => } - renderTags={(value, getTagProps) => - value.map((p, index) => ( - pp.id === p)?.label ?? p} size="small" /> - )) - } - /> - handleRemoveDep(permId)} color="error"> - - + {fgDeps.map(([permId, deps]) => ( + + + {formatPermLabel(permId)} + + benötigt + p !== permId)} + getOptionLabel={formatPermLabel} + value={deps.filter(d => sameGroupPermOptions.includes(d))} + onChange={(_e, val) => handleDepChange(permId, val)} + renderInput={(params) => } + renderTags={(value, getTagProps) => + value.map((p, index) => ( + + )) + } + /> + handleRemoveDep(permId)} color="error"> + + + + ))} ); })} @@ -650,9 +707,13 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis {/* Add new dependency */} !editDeps[p])} - getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} + size="small" sx={{ width: 350 }} + options={allPermOptions} + groupBy={(option) => { + const fg = featureGroups.find(f => f.id === permToFeatureGroup[option]); + return fg?.label ?? 'Sonstige'; + }} + getOptionLabel={formatPermLabel} value={newDepPerm} onChange={(_e, val) => setNewDepPerm(val)} renderInput={(params) => }