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'],
},
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<string, string[]>;
allGroups: string[];
allPermissions: Permission[];
featureGroups: FeatureGroup[];
onSave: (config: { groupHierarchy?: Record<string, string[]>; permissionDeps?: Record<string, string[]> }) => 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<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
const [editHierarchy, setEditHierarchy] = useState<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 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<string | null>(null);
// All permission options for "add new dependency" picker
const allPermOptions = allPermissions.map(p => p.id).filter(p => !editDeps[p]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, mt: 2 }}>
{/* Group Hierarchy Editor */}
@@ -610,39 +653,53 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
))}
</Box>
{/* Permission Dependency Editor */}
{/* Permission Dependency Editor — Grouped by Feature Group */}
<Box>
<Typography variant="subtitle1" gutterBottom sx={{ fontWeight: 'bold' }}>
Berechtigungsabhängigkeiten
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
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>
{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 (
<Box key={permId} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Typography variant="body2" sx={{ minWidth: 220, fontWeight: 500 }}>
{perm?.label ?? permId}
<Box key={fg.id} sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary', mb: 1, textTransform: 'uppercase', fontSize: '0.75rem', letterSpacing: 0.5 }}>
{fg.label}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}>benötigt</Typography>
<Autocomplete
multiple size="small" sx={{ flex: 1 }}
options={permOptions.filter(p => p !== permId)}
getOptionLabel={(p) => allPermissions.find(pp => pp.id === p)?.label ?? p}
value={deps}
onChange={(_e, val) => handleDepChange(permId, val)}
renderInput={(params) => <TextField {...params} placeholder="Voraussetzungen..." size="small" />}
renderTags={(value, getTagProps) =>
value.map((p, index) => (
<Chip {...getTagProps({ index })} key={p}
label={allPermissions.find(pp => pp.id === p)?.label ?? p} size="small" />
))
}
/>
<IconButton size="small" onClick={() => handleRemoveDep(permId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
{fgDeps.map(([permId, deps]) => (
<Box key={permId} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5, ml: 2 }}>
<Typography variant="body2" sx={{ minWidth: 220, fontWeight: 500 }}>
{formatPermLabel(permId)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mx: 1 }}>benötigt</Typography>
<Autocomplete
multiple size="small" sx={{ flex: 1 }}
options={sameGroupPermOptions.filter(p => p !== permId)}
getOptionLabel={formatPermLabel}
value={deps.filter(d => sameGroupPermOptions.includes(d))}
onChange={(_e, val) => handleDepChange(permId, val)}
renderInput={(params) => <TextField {...params} placeholder="Voraussetzungen..." size="small" />}
renderTags={(value, getTagProps) =>
value.map((p, index) => (
<Chip {...getTagProps({ index })} key={p} label={formatPermLabel(p)} size="small" />
))
}
/>
<IconButton size="small" onClick={() => handleRemoveDep(permId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
</Box>
);
})}
@@ -650,9 +707,13 @@ function DependencyEditor({ groupHierarchy, permissionDeps, allGroups, allPermis
{/* Add new dependency */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Autocomplete
size="small" sx={{ width: 300 }}
options={permOptions.filter(p => !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) => <TextField {...params} placeholder="Neue Abhängigkeit hinzufügen..." size="small" />}