feat: add multi-administrator support with RBAC and fix backup/restore for S3

## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes

## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes

## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table

## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
This commit is contained in:
Paul Nothaft
2026-01-07 17:10:46 +01:00
parent b706eeb5d3
commit 892e47d017
65 changed files with 4854 additions and 493 deletions
@@ -1,11 +1,13 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { AdminAuthProvider } from '../../contexts';
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
export const AdminAuthWrapper: React.FC = () => {
return (
<AdminAuthProvider>
<Outlet />
<PermissionsProvider>
<Outlet />
</PermissionsProvider>
</AdminAuthProvider>
);
};
+39 -31
View File
@@ -1,21 +1,23 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
Settings,
X,
Palette,
FileText,
HardDrive
HardDrive,
Users
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
interface AdminSidebarProps {
isOpen: boolean;
@@ -26,23 +28,32 @@ interface NavItem {
nameKey: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string;
}
const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
const { t } = useTranslation();
const { hasPermission } = usePermissions();
// Filter navigation items based on permissions
const filteredNavigation = navigation.filter(item => {
if (!item.permission) return true;
return hasPermission(item.permission);
});
return (
<div
@@ -66,7 +77,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
{/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
@@ -90,14 +101,16 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
})}
</nav>
{/* Bottom section - sticky to bottom */}
<div className="flex-shrink-0">
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
{hasPermission('settings.view') && (
<div className="flex-shrink-0">
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
)}
</div>
</div>
);
@@ -111,14 +124,9 @@ const StorageInfo: React.FC = () => {
refetchInterval: 60000 // Refresh every minute
});
// Don't render anything while loading or if data failed to load
if (!storageInfo) {
return (
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="h-12 animate-pulse bg-neutral-200 rounded" />
</div>
</div>
);
return null;
}
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
@@ -0,0 +1,60 @@
import React from 'react';
import type { ReactNode } from 'react';
import { usePermissions } from '../../contexts/PermissionsContext';
interface PermissionGateProps {
permission?: string;
permissions?: string[];
requireAll?: boolean;
fallback?: ReactNode;
children: ReactNode;
}
/**
* PermissionGate component that conditionally renders children based on user permissions.
*
* @param permission - A single permission to check
* @param permissions - An array of permissions to check
* @param requireAll - If true, requires all permissions (AND logic). If false, requires any permission (OR logic). Default: false
* @param fallback - Content to render if permission check fails. Default: null
* @param children - Content to render if permission check passes
*/
export const PermissionGate: React.FC<PermissionGateProps> = ({
permission,
permissions,
requireAll = false,
fallback = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions, isSuperAdmin } = usePermissions();
// Super admin bypasses all permission checks
if (isSuperAdmin) {
return <>{children}</>;
}
// Check single permission
if (permission) {
if (hasPermission(permission)) {
return <>{children}</>;
}
return <>{fallback}</>;
}
// Check multiple permissions
if (permissions && permissions.length > 0) {
const hasAccess = requireAll
? hasAllPermissions(permissions)
: hasAnyPermission(permissions);
if (hasAccess) {
return <>{children}</>;
}
return <>{fallback}</>;
}
// If no permissions specified, render children (allow access)
return <>{children}</>;
};
PermissionGate.displayName = 'PermissionGate';
@@ -500,13 +500,18 @@ export const RestoreWizard = () => {
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span>
<span className="font-medium">
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span>
<span className="font-medium">
{validationResult.spaceCheck.availableFormatted ||
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
</span>
</div>
{!validationResult.spaceCheck.sufficient && (
{validationResult.spaceCheck.sufficient === false && (
<p className="text-red-600 text-xs mt-2">
<AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.restore.confirmation.spaceCheck.insufficient')}