new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 17:18:38 +01:00
parent da08948ca8
commit 269b797f42
2 changed files with 262 additions and 31 deletions

View File

@@ -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');

View File

@@ -94,16 +94,30 @@ const PERMISSION_SUB_GROUPS: Record<string, Record<string, string[]>> = {
'Buchungen': ['view_bookings', 'manage_bookings'], 'Buchungen': ['view_bookings', 'manage_bookings'],
}, },
bestellungen: { bestellungen: {
'Bestellungen': ['view', 'create', 'delete', 'export'], 'Bestellungen': ['view', 'create', 'manage_orders', 'delete', 'export'],
'Lieferanten': ['manage_vendors'], 'Lieferanten': ['manage_vendors'],
'Erinnerungen': ['manage_reminders'], 'Erinnerungen': ['manage_reminders'],
'Widget': ['widget'], 'Widget': ['widget'],
}, },
shop: { shop: {
'Katalog': ['view', 'manage_catalog'], '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'], '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 { function getSubGroupLabel(featureGroupId: string, permId: string): string | null {
@@ -327,6 +341,7 @@ function PermissionMatrixTab() {
permissionDeps={permissionDeps} permissionDeps={permissionDeps}
allGroups={nonAdminGroups} allGroups={nonAdminGroups}
allPermissions={permissions} allPermissions={permissions}
featureGroups={featureGroups}
onSave={(config) => depConfigMutation.mutate(config)} onSave={(config) => depConfigMutation.mutate(config)}
isSaving={depConfigMutation.isPending} isSaving={depConfigMutation.isPending}
/> />
@@ -512,14 +527,40 @@ interface DependencyEditorProps {
permissionDeps: Record<string, string[]>; permissionDeps: Record<string, string[]>;
allGroups: string[]; allGroups: string[];
allPermissions: Permission[]; allPermissions: Permission[];
featureGroups: FeatureGroup[];
onSave: (config: { groupHierarchy?: Record<string, string[]>; permissionDeps?: Record<string, string[]> }) => void; onSave: (config: { groupHierarchy?: Record<string, string[]>; permissionDeps?: Record<string, string[]> }) => void;
isSaving: boolean; 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 groupSet = useMemo(() => new Set(allGroups), [allGroups]);
const permIdSet = useMemo(() => new Set(allPermissions.map(p => p.id)), [allPermissions]); const permIdSet = useMemo(() => new Set(allPermissions.map(p => p.id)), [allPermissions]);
// Build lookup: permId → feature_group_id
const permToFeatureGroup = useMemo(() => {
const map: Record<string, string> = {};
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<string, Permission[]> = {};
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 // Filter saved config to only include groups/permissions that actually exist
const [editHierarchy, setEditHierarchy] = useState<Record<string, string[]>>(() => { const [editHierarchy, setEditHierarchy] = useState<Record<string, string[]>>(() => {
const filtered: Record<string, string[]> = {}; const filtered: Record<string, string[]> = {};
@@ -541,7 +582,6 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
}); });
const nonAdminGroups = allGroups.filter(g => g !== 'dashboard_admin'); const nonAdminGroups = allGroups.filter(g => g !== 'dashboard_admin');
const permOptions = allPermissions.map(p => p.id);
const handleHierarchyChange = (group: string, inheritors: string[]) => { const handleHierarchyChange = (group: string, inheritors: string[]) => {
setEditHierarchy(prev => { setEditHierarchy(prev => {
@@ -577,6 +617,9 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
const [newDepPerm, setNewDepPerm] = useState<string | null>(null); const [newDepPerm, setNewDepPerm] = useState<string | null>(null);
// All permission options for "add new dependency" picker
const allPermOptions = allPermissions.map(p => p.id).filter(p => !editDeps[p]);
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
{/* Group Hierarchy Editor */} {/* Group Hierarchy Editor */}
@@ -610,39 +653,53 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
))} ))}
</Box> </Box>
{/* Permission Dependency Editor */} {/* Permission Dependency Editor — Grouped by Feature Group */}
<Box> <Box>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}> <Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Berechtigungsabhängigkeiten Berechtigungsabhängigkeiten
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Wenn eine Berechtigung gesetzt wird, werden die hier definierten Voraussetzungen automatisch mit aktiviert. Wenn eine Berechtigung gesetzt wird, werden die hier definierten Voraussetzungen automatisch mit aktiviert.
Abhängigkeiten können nur innerhalb derselben Feature-Gruppe gesetzt werden.
</Typography> </Typography>
{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 ( return (
<Box key={permId} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}> <Box key={fg.id} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ minWidth: 220, fontWeight: 500 }}> <Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary', mb: 1, textTransform: 'uppercase', fontSize: '0.75rem', letterSpacing: 0.5 }}>
{perm?.label ?? permId} {fg.label}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}>benötigt</Typography> {fgDeps.map(([permId, deps]) => (
<Autocomplete <Box key={permId} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, ml: 2 }}>
multiple size="small" sx={{ flex: 1 }} <Typography variant="body2" sx={{ minWidth: 220, fontWeight: 500 }}>
options={permOptions.filter(p => p !== permId)} {formatPermLabel(permId)}
getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} </Typography>
value={deps} <Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}>benötigt</Typography>
onChange={(_e, val) => handleDepChange(permId, val)} <Autocomplete
renderInput={(params) => <TextField {...params} placeholder="Voraussetzungen..." size="small" />} multiple size="small" sx={{ flex: 1 }}
renderTags={(value, getTagProps) => options={sameGroupPermOptions.filter(p => p !== permId)}
value.map((p, index) => ( getOptionLabel={formatPermLabel}
<Chip {...getTagProps({ index })} key={p} value={deps.filter(d => sameGroupPermOptions.includes(d))}
label={allPermissions.find(pp => pp.id === p)?.label ?? p} size="small" /> onChange={(_e, val) => handleDepChange(permId, val)}
)) renderInput={(params) => <TextField {...params} placeholder="Voraussetzungen..." size="small" />}
} renderTags={(value, getTagProps) =>
/> value.map((p, index) => (
<IconButton size="small" onClick={() => handleRemoveDep(permId)} color="error"> <Chip {...getTagProps({ index })} key={p} label={formatPermLabel(p)} size="small" />
<DeleteIcon fontSize="small" /> ))
</IconButton> }
/>
<IconButton size="small" onClick={() => handleRemoveDep(permId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
</Box> </Box>
); );
})} })}
@@ -650,9 +707,13 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
{/* Add new dependency */} {/* Add new dependency */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Autocomplete <Autocomplete
size="small" sx={{ width: 300 }} size="small" sx={{ width: 350 }}
options={permOptions.filter(p => !editDeps[p])} options={allPermOptions}
getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p} groupBy={(option) => {
const fg = featureGroups.find(f => f.id === permToFeatureGroup[option]);
return fg?.label ?? 'Sonstige';
}}
getOptionLabel={formatPermLabel}
value={newDepPerm} value={newDepPerm}
onChange={(_e, val) => setNewDepPerm(val)} onChange={(_e, val) => setNewDepPerm(val)}
renderInput={(params) => <TextField {...params} placeholder="Neue Abhängigkeit hinzufügen..." size="small" />} renderInput={(params) => <TextField {...params} placeholder="Neue Abhängigkeit hinzufügen..." size="small" />}