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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user