feat: Multi-administrator RBAC, CSS templates & security hardening (#78)

- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
This commit is contained in:
Paul Nothaft
2026-01-07 21:53:25 +01:00
committed by GitHub
71 changed files with 5006 additions and 527 deletions
+5 -5
View File
@@ -30,13 +30,13 @@ COPY . .
# Build the application
RUN npm run build
# Production stage (use Alpine with patched libpng)
FROM nginx:1.27-alpine3.21
# Production stage (use Alpine with patched libpng/c-ares)
FROM nginx:1.27-alpine
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
# Upgrade all packages to fix security vulnerabilities
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
RUN apk upgrade --no-cache
# Ensure libpng includes CVE fixes (pull patched version from edge)
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main 'libpng>=1.6.51-r0'
# Install runtime dependencies
RUN apk add --no-cache curl
+50 -61
View File
@@ -1088,9 +1088,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1604,9 +1604,9 @@
]
},
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1614,12 +1614,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
"@tanstack/query-core": "5.90.16"
},
"funding": {
"type": "github",
@@ -1688,9 +1688,9 @@
}
},
"node_modules/@testing-library/react": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2935,9 +2935,9 @@
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"dev": true,
"funding": [
{
@@ -2955,10 +2955,9 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.27.0",
"caniuse-lite": "^1.0.30001754",
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001760",
"fraction.js": "^5.3.4",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
@@ -2991,9 +2990,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"version": "2.9.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3038,9 +3037,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -3059,11 +3058,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
"electron-to-chromium": "^1.5.249",
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4"
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -3116,9 +3115,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true,
"funding": [
{
@@ -3522,9 +3521,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3545,9 +3544,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.262",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -3680,9 +3679,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -3693,7 +3692,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -3754,9 +3753,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4325,9 +4324,9 @@
}
},
"node_modules/i18next": {
"version": "25.6.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
"version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [
{
"type": "individual",
@@ -5022,16 +5021,6 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nwsapi": {
"version": "2.2.22",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
@@ -6517,9 +6506,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
+10 -4
View File
@@ -10,9 +10,9 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
AdminLoginPage,
AdminDashboard,
import {
AdminLoginPage,
AdminDashboard,
EventsListPage,
CreateEventPage,
EventDetailsPage,
@@ -23,8 +23,10 @@ import {
BrandingPage,
SettingsPage,
BackupManagement,
CMSPage
CMSPage,
UserManagementPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -128,10 +130,14 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
@@ -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')}
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -16,6 +17,10 @@ interface ThemeCustomizerEnhancedProps {
hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
isApplying?: boolean;
// CSS Template props
cssTemplates?: EnabledTemplate[];
cssTemplateId?: number | null;
onCssTemplateChange?: (templateId: number | null) => void;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
@@ -38,7 +43,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
showGalleryLayouts = true,
hideActions = false,
onApply,
isApplying = false
isApplying = false,
cssTemplates,
cssTemplateId,
onCssTemplateChange
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -566,11 +574,67 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</Card>
{/* Custom CSS */}
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{t('branding.cssTemplate', 'CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* No template option */}
<button
onClick={() => onCssTemplateChange(null)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
!cssTemplateId
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{t('branding.noTemplate', 'No Template')}</span>
{!cssTemplateId && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
</span>
</button>
{/* Template options */}
{cssTemplates.map((template) => (
<button
key={template.id}
onClick={() => onCssTemplateChange(template.id)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
cssTemplateId === template.id
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{template.name}</span>
{cssTemplateId === template.id && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
</span>
</button>
))}
</div>
</Card>
)}
{/* Event-specific Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.customCSS')}
{t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
</h3>
{/* Collapsible Instructions Panel */}
@@ -4,13 +4,15 @@ import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (theme: ThemeConfig, presetName: string) => void;
onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void;
currentTheme: ThemeConfig | string;
currentCssTemplateId?: number | null;
eventName: string;
}
@@ -28,12 +30,29 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClose,
onSave,
currentTheme,
currentCssTemplateId,
eventName
}) => {
const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
const [cssTemplateId, setCssTemplateId] = useState<number | null>(currentCssTemplateId ?? null);
// Fetch CSS templates when modal opens
useEffect(() => {
if (isOpen) {
cssTemplatesService.getEnabledTemplates()
.then(setCssTemplates)
.catch(err => console.error('Failed to load CSS templates:', err));
}
}, [isOpen]);
// Update cssTemplateId when prop changes
useEffect(() => {
setCssTemplateId(currentCssTemplateId ?? null);
}, [currentCssTemplateId]);
useEffect(() => {
if (currentTheme) {
@@ -82,7 +101,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
};
const handleSave = () => {
onSave(theme, presetName);
onSave(theme, presetName, cssTemplateId);
onClose();
};
@@ -128,6 +147,9 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={cssTemplateId}
onCssTemplateChange={setCssTemplateId}
/>
</div>
@@ -456,7 +456,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onClick={() => {
setShowFeedback(!showFeedback);
}}
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
className="relative p-2 bg-black/40 hover:bg-black/60 rounded-full border border-white/40 transition-colors"
aria-label="Toggle feedback"
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
>
@@ -164,7 +164,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
});
} catch (_) {}
}}
className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
className={`bg-black/30 hover:bg-black/50 rounded-full border border-white/40 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)}
>
@@ -176,7 +176,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost"
size="sm"
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
className="text-white hover:bg-white/20"
className="text-white bg-black/30 hover:bg-black/50 rounded-full border border-white/40"
title="Comment"
aria-label="Comment on photo"
>
@@ -0,0 +1,121 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { api } from '../config/api';
import { useAdminAuth } from './AdminAuthContext';
import type { AdminPermissions } from '../types';
interface PermissionsContextType {
permissions: string[];
role: { name: string; displayName: string } | null;
hasPermission: (permission: string) => boolean;
hasAnyPermission: (permissions: string[]) => boolean;
hasAllPermissions: (permissions: string[]) => boolean;
isSuperAdmin: boolean;
isLoading: boolean;
refresh: () => Promise<void>;
}
const PermissionsContext = createContext<PermissionsContextType | undefined>(undefined);
export const usePermissions = () => {
const context = useContext(PermissionsContext);
if (!context) {
throw new Error('usePermissions must be used within a PermissionsProvider');
}
return context;
};
interface PermissionsProviderProps {
children: ReactNode;
}
export const PermissionsProvider: React.FC<PermissionsProviderProps> = ({ children }) => {
const { isAuthenticated } = useAdminAuth();
const [permissions, setPermissions] = useState<string[]>([]);
const [role, setRole] = useState<{ name: string; displayName: string } | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchPermissions = useCallback(async () => {
if (!isAuthenticated) {
setPermissions([]);
setRole(null);
setIsLoading(false);
return;
}
try {
setIsLoading(true);
const response = await api.get<AdminPermissions>('/admin/users/me/permissions');
setPermissions(response.data.permissions || []);
setRole(response.data.role || null);
} catch (error) {
// Clear permissions on auth failure
setPermissions([]);
setRole(null);
} finally {
setIsLoading(false);
}
}, [isAuthenticated]);
useEffect(() => {
fetchPermissions();
}, [fetchPermissions]);
const hasPermission = useCallback(
(permission: string): boolean => {
// Super admin has all permissions
if (role?.name === 'super_admin') {
return true;
}
return permissions.includes(permission);
},
[permissions, role]
);
const hasAnyPermission = useCallback(
(perms: string[]): boolean => {
// Super admin has all permissions
if (role?.name === 'super_admin') {
return true;
}
return perms.some((p) => permissions.includes(p));
},
[permissions, role]
);
const hasAllPermissions = useCallback(
(perms: string[]): boolean => {
// Super admin has all permissions
if (role?.name === 'super_admin') {
return true;
}
return perms.every((p) => permissions.includes(p));
},
[permissions, role]
);
const isSuperAdmin = role?.name === 'super_admin';
const refresh = useCallback(async () => {
await fetchPermissions();
}, [fetchPermissions]);
return (
<PermissionsContext.Provider
value={{
permissions,
role,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isSuperAdmin,
isLoading,
refresh,
}}
>
{children}
</PermissionsContext.Provider>
);
};
export { PermissionsContext };
+2 -1
View File
@@ -3,4 +3,5 @@ export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
export { PermissionsProvider, usePermissions, PermissionsContext } from './PermissionsContext';
@@ -48,7 +48,7 @@ export const ImageSecurityTab: React.FC = () => {
const { data: fetchedSettings, isLoading, error } = useQuery({
queryKey: ['image-security-settings'],
queryFn: async () => {
const response = await api.get('/api/admin/image-security/settings');
const response = await api.get('/admin/image-security/settings');
return response.data;
},
});
@@ -66,7 +66,7 @@ export const ImageSecurityTab: React.FC = () => {
// Save mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: ImageSecuritySettings) => {
const response = await api.put('/api/admin/image-security/settings', newSettings);
const response = await api.put('/admin/image-security/settings', newSettings);
return response.data;
},
onSuccess: () => {
+2 -1
View File
@@ -1,4 +1,5 @@
export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
export * from './useLocalizedTimeAgo';
export * from './usePermission';
+23
View File
@@ -0,0 +1,23 @@
import { usePermissions } from '../contexts/PermissionsContext';
/**
* Hook to check if the current user has a specific permission.
*
* @param permission - The permission to check
* @returns boolean indicating if the user has the permission
*/
export function usePermission(permission: string): boolean {
const { hasPermission } = usePermissions();
return hasPermission(permission);
}
/**
* Hook to check if the current user has any of the specified permissions.
*
* @param permissions - Array of permissions to check
* @returns boolean indicating if the user has any of the permissions
*/
export function useAnyPermission(permissions: string[]): boolean {
const { hasAnyPermission } = usePermissions();
return hasAnyPermission(permissions);
}
+162 -1
View File
@@ -1,4 +1,78 @@
{
"userManagement": {
"title": "Benutzerverwaltung",
"subtitle": "Admin-Benutzer und Einladungen verwalten",
"loading": "Lade Benutzer...",
"loadError": "Fehler beim Laden der Benutzer. Bitte versuchen Sie es erneut.",
"inviteUser": "Benutzer einladen",
"createInvitation": "Einladung erstellen",
"email": "E-Mail-Adresse",
"emailPlaceholder": "[email protected]",
"role": "Rolle",
"selectRole": "Rolle auswählen",
"sendInvitation": "Einladung senden",
"editUser": "Benutzer bearbeiten",
"editingUser": "Bearbeite Benutzer",
"saveChanges": "Änderungen speichern",
"deactivateUser": "Benutzer deaktivieren",
"cancelInvitation": "Einladung abbrechen",
"invitationSent": "Einladung erfolgreich gesendet",
"invitationError": "Fehler beim Senden der Einladung",
"invitationCancelled": "Einladung abgebrochen",
"cancelInvitationError": "Fehler beim Abbrechen der Einladung",
"userUpdated": "Benutzer erfolgreich aktualisiert",
"updateUserError": "Fehler beim Aktualisieren des Benutzers",
"userDeactivated": "Benutzer erfolgreich deaktiviert",
"deactivateUserError": "Fehler beim Deaktivieren des Benutzers",
"noRole": "Keine Rolle",
"neverLoggedIn": "Nie angemeldet",
"expired": "Abgelaufen",
"deactivate": "Deaktivieren",
"cancel": "Abbrechen",
"tabs": {
"users": "Benutzer",
"invitations": "Einladungen"
},
"stats": {
"totalUsers": "Benutzer gesamt",
"activeUsers": "Aktive Benutzer",
"pendingInvitations": "Ausstehende Einladungen",
"inactiveUsers": "Inaktive Benutzer"
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv"
},
"table": {
"user": "Benutzer",
"email": "E-Mail",
"role": "Rolle",
"status": "Status",
"lastLogin": "Letzte Anmeldung",
"actions": "Aktionen",
"invitedBy": "Eingeladen von",
"expires": "Läuft ab"
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Ungültiges E-Mail-Format",
"roleRequired": "Rolle ist erforderlich"
},
"searchUsersPlaceholder": "Benutzer suchen...",
"searchInvitationsPlaceholder": "Einladungen suchen...",
"noUsers": "Keine Benutzer gefunden",
"noUsersFound": "Keine Benutzer entsprechen Ihrer Suche",
"noInvitations": "Keine ausstehenden Einladungen",
"noInvitationsFound": "Keine Einladungen entsprechen Ihrer Suche",
"confirmDeactivate": {
"title": "Benutzer deaktivieren",
"message": "Sind Sie sicher, dass Sie {{name}} deaktivieren möchten? Sie können sich dann nicht mehr anmelden."
},
"confirmCancelInvitation": {
"title": "Einladung abbrechen",
"message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?"
}
},
"common": {
"loading": "Wird geladen...",
"error": "Fehler",
@@ -84,7 +158,8 @@
"analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen",
"backup": "Backup & Wiederherstellung",
"cmsPages": "CMS-Seiten"
"cmsPages": "CMS-Seiten",
"users": "Benutzer"
},
"backup": {
"external": {
@@ -153,6 +228,10 @@
"title": "Backup nicht konfiguriert",
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen."
},
"actions": {
"runBackupNow": "Backup jetzt starten",
"running": "Läuft..."
},
"coverage": {
"title": "Backup-Abdeckung",
"database": "Datenbank",
@@ -1282,6 +1361,88 @@
"admin_logout": "Admin {{actorName}} abgemeldet",
"system_activity": "Systemaktivität: {{type}}",
"unknown": "Unbekannte Aktivität"
},
"userManagement": "Benutzerverwaltung",
"inviteUser": "Benutzer einladen",
"pendingInvitations": "Ausstehende Einladungen",
"roles": {
"super_admin": "Super-Admin",
"admin": "Admin",
"editor": "Redakteur",
"viewer": "Betrachter"
},
"userStatus": {
"active": "Aktiv",
"inactive": "Inaktiv"
},
"inviteForm": {
"email": "E-Mail-Adresse",
"role": "Rolle",
"send": "Einladung senden"
},
"acceptInvite": {
"title": "Admin-Einladung annehmen",
"username": "Benutzernamen wählen",
"password": "Passwort erstellen",
"submit": "Konto erstellen"
}
},
"permissions": {
"insufficient": "Sie haben keine Berechtigung, diese Aktion auszuführen",
"viewOnly": "Nur Ansicht"
},
"acceptInvitation": {
"title": "Einladung annehmen",
"subtitle": "Erstellen Sie Ihr Administratorkonto",
"validating": "Einladung wird überprüft...",
"invalidToken": "Ungültige Einladung",
"invalidTokenMessage": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Administrator für eine neue Einladung.",
"expiredToken": "Einladung abgelaufen",
"expiredTokenMessage": "Diese Einladung ist abgelaufen. Bitte fordern Sie eine neue Einladung von Ihrem Administrator an.",
"alreadyUsed": "Einladung bereits verwendet",
"alreadyUsedMessage": "Diese Einladung wurde bereits verwendet, um ein Konto zu erstellen.",
"invitedAs": "Sie wurden eingeladen als",
"expiresAt": "Einladung läuft ab",
"usernameLabel": "Benutzername",
"usernamePlaceholder": "Wählen Sie einen Benutzernamen",
"usernameHelp": "3-50 Zeichen, nur Buchstaben, Zahlen, Unterstriche und Bindestriche",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Erstellen Sie ein sicheres Passwort",
"confirmPasswordLabel": "Passwort bestätigen",
"confirmPasswordPlaceholder": "Bestätigen Sie Ihr Passwort",
"passwordStrength": "Passwortstärke",
"requirements": {
"title": "Passwortanforderungen:",
"minLength": "Mindestens 12 Zeichen",
"uppercase": "Mindestens ein Großbuchstabe",
"lowercase": "Mindestens ein Kleinbuchstabe",
"number": "Mindestens eine Zahl",
"special": "Mindestens ein Sonderzeichen"
},
"strength": {
"weak": "Schwach",
"fair": "Mittel",
"good": "Gut",
"strong": "Stark"
},
"createAccount": "Konto erstellen",
"creating": "Konto wird erstellt...",
"success": "Konto erstellt!",
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
"goToLogin": "Zur Anmeldung",
"errors": {
"usernameRequired": "Benutzername ist erforderlich",
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",
"usernameTooLong": "Benutzername darf maximal 50 Zeichen lang sein",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten",
"passwordRequired": "Passwort ist erforderlich",
"passwordTooShort": "Passwort muss mindestens 12 Zeichen lang sein",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
"emailTaken": "Ein Konto mit dieser E-Mail-Adresse existiert bereits",
"genericError": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
}
},
"errors": {
+162 -1
View File
@@ -1,4 +1,78 @@
{
"userManagement": {
"title": "User Management",
"subtitle": "Manage admin users and invitations",
"loading": "Loading users...",
"loadError": "Failed to load users. Please try again.",
"inviteUser": "Invite User",
"createInvitation": "Create Invitation",
"email": "Email Address",
"emailPlaceholder": "[email protected]",
"role": "Role",
"selectRole": "Select a role",
"sendInvitation": "Send Invitation",
"editUser": "Edit User",
"editingUser": "Editing user",
"saveChanges": "Save Changes",
"deactivateUser": "Deactivate User",
"cancelInvitation": "Cancel Invitation",
"invitationSent": "Invitation sent successfully",
"invitationError": "Failed to send invitation",
"invitationCancelled": "Invitation cancelled",
"cancelInvitationError": "Failed to cancel invitation",
"userUpdated": "User updated successfully",
"updateUserError": "Failed to update user",
"userDeactivated": "User deactivated successfully",
"deactivateUserError": "Failed to deactivate user",
"noRole": "No Role",
"neverLoggedIn": "Never logged in",
"expired": "Expired",
"deactivate": "Deactivate",
"cancel": "Cancel",
"tabs": {
"users": "Users",
"invitations": "Invitations"
},
"stats": {
"totalUsers": "Total Users",
"activeUsers": "Active Users",
"pendingInvitations": "Pending Invitations",
"inactiveUsers": "Inactive Users"
},
"status": {
"active": "Active",
"inactive": "Inactive"
},
"table": {
"user": "User",
"email": "Email",
"role": "Role",
"status": "Status",
"lastLogin": "Last Login",
"actions": "Actions",
"invitedBy": "Invited By",
"expires": "Expires"
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"roleRequired": "Role is required"
},
"searchUsersPlaceholder": "Search users...",
"searchInvitationsPlaceholder": "Search invitations...",
"noUsers": "No users found",
"noUsersFound": "No users match your search",
"noInvitations": "No pending invitations",
"noInvitationsFound": "No invitations match your search",
"confirmDeactivate": {
"title": "Deactivate User",
"message": "Are you sure you want to deactivate {{name}}? They will no longer be able to log in."
},
"confirmCancelInvitation": {
"title": "Cancel Invitation",
"message": "Are you sure you want to cancel the invitation for {{email}}?"
}
},
"common": {
"loading": "Loading...",
"error": "Error",
@@ -84,7 +158,8 @@
"analytics": "Analytics",
"emailSettings": "Email Settings",
"backup": "Backup & Restore",
"cmsPages": "CMS Pages"
"cmsPages": "CMS Pages",
"users": "Users"
},
"archives": {
"title": "Archives",
@@ -1015,6 +1090,88 @@
"admin_logout": "Admin {{actorName}} logged out",
"system_activity": "System activity: {{type}}",
"unknown": "Unknown activity"
},
"userManagement": "User Management",
"inviteUser": "Invite User",
"pendingInvitations": "Pending Invitations",
"roles": {
"super_admin": "Super Admin",
"admin": "Admin",
"editor": "Editor",
"viewer": "Viewer"
},
"userStatus": {
"active": "Active",
"inactive": "Inactive"
},
"inviteForm": {
"email": "Email Address",
"role": "Role",
"send": "Send Invitation"
},
"acceptInvite": {
"title": "Accept Admin Invitation",
"username": "Choose a Username",
"password": "Create Password",
"submit": "Create Account"
}
},
"permissions": {
"insufficient": "You don't have permission to perform this action",
"viewOnly": "View Only"
},
"acceptInvitation": {
"title": "Accept Invitation",
"subtitle": "Create your admin account",
"validating": "Validating invitation...",
"invalidToken": "Invalid Invitation",
"invalidTokenMessage": "This invitation link is invalid or has expired. Please contact your administrator for a new invitation.",
"expiredToken": "Invitation Expired",
"expiredTokenMessage": "This invitation has expired. Please request a new invitation from your administrator.",
"alreadyUsed": "Invitation Already Used",
"alreadyUsedMessage": "This invitation has already been used to create an account.",
"invitedAs": "You've been invited as",
"expiresAt": "Invitation expires",
"usernameLabel": "Username",
"usernamePlaceholder": "Choose a username",
"usernameHelp": "3-50 characters, letters, numbers, underscores, and hyphens only",
"passwordLabel": "Password",
"passwordPlaceholder": "Create a strong password",
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Confirm your password",
"passwordStrength": "Password strength",
"requirements": {
"title": "Password requirements:",
"minLength": "At least 12 characters",
"uppercase": "At least one uppercase letter",
"lowercase": "At least one lowercase letter",
"number": "At least one number",
"special": "At least one special character"
},
"strength": {
"weak": "Weak",
"fair": "Fair",
"good": "Good",
"strong": "Strong"
},
"createAccount": "Create Account",
"creating": "Creating account...",
"success": "Account Created!",
"successMessage": "Your account has been created successfully. You can now log in with your credentials.",
"redirecting": "Redirecting to login in {{seconds}}...",
"goToLogin": "Go to Login",
"errors": {
"usernameRequired": "Username is required",
"usernameTooShort": "Username must be at least 3 characters",
"usernameTooLong": "Username must be at most 50 characters",
"usernameInvalid": "Username can only contain letters, numbers, underscores, and hyphens",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 12 characters",
"passwordsDoNotMatch": "Passwords do not match",
"confirmPasswordRequired": "Please confirm your password",
"usernameTaken": "This username is already taken",
"emailTaken": "An account with this email already exists",
"genericError": "Failed to create account. Please try again."
}
},
"errors": {
@@ -1233,6 +1390,10 @@
"title": "Backup Not Configured",
"message": "Please configure backup settings in the Configuration tab before running backups."
},
"actions": {
"runBackupNow": "Run Backup Now",
"running": "Running..."
},
"coverage": {
"title": "Backup Coverage",
"database": "Database",
+19 -1
View File
@@ -58,6 +58,7 @@ import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
const resolveShareLink = (link: string): string => {
@@ -141,6 +142,7 @@ export const EventDetailsPage: React.FC = () => {
type EditFormState = {
welcome_message: string;
color_theme: string;
css_template_id: number | null;
expires_at: string;
allow_user_uploads: boolean;
upload_category_id: number | null;
@@ -164,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [editForm, setEditForm] = useState<EditFormState>({
welcome_message: '',
color_theme: '',
css_template_id: null,
expires_at: '',
allow_user_uploads: false,
upload_category_id: null,
@@ -207,7 +210,17 @@ export const EventDetailsPage: React.FC = () => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
// Fetch CSS templates when component mounts or editing starts
useEffect(() => {
if (isEditing) {
cssTemplatesService.getEnabledTemplates()
.then(setCssTemplates)
.catch(err => console.error('Failed to load CSS templates:', err));
}
}, [isEditing]);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
category_id: undefined as number | null | undefined,
@@ -375,6 +388,7 @@ export const EventDetailsPage: React.FC = () => {
setEditForm({
welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '',
css_template_id: event.css_template_id || null,
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
@@ -474,6 +488,7 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
// Download protection settings
protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click,
@@ -1357,6 +1372,9 @@ export const EventDetailsPage: React.FC = () => {
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={editForm.css_template_id}
onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
/>
</Card>
)}
@@ -0,0 +1,973 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
Users,
Mail,
Plus,
Search,
Edit,
UserX,
X,
AlertTriangle,
Clock,
Shield,
Trash2,
CheckCircle,
XCircle,
} from 'lucide-react';
import { parseISO, formatDistanceToNow, isPast } from 'date-fns';
import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
type TabType = 'users' | 'invitations';
// Role badge colors
const getRoleBadgeColor = (roleName: string): string => {
switch (roleName?.toLowerCase()) {
case 'super_admin':
return 'bg-red-100 text-red-700 border-red-200';
case 'admin':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'editor':
return 'bg-green-100 text-green-700 border-green-200';
case 'viewer':
default:
return 'bg-neutral-100 text-neutral-700 border-neutral-200';
}
};
// Modal component for creating invitations
interface CreateInvitationModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (email: string, roleId: number) => void;
roles: AdminRole[];
isLoading: boolean;
}
const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
isOpen,
onClose,
onSubmit,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState<number | ''>('');
const [errors, setErrors] = useState<{ email?: string; role?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: { email?: string; role?: string } = {};
if (!email) {
newErrors.email = t('userManagement.validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('userManagement.validation.emailInvalid');
}
if (!roleId) {
newErrors.role = t('userManagement.validation.roleRequired');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(email, roleId as number);
};
const handleClose = () => {
setEmail('');
setRoleId('');
setErrors({});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.createInvitation')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.email')}
</label>
<Input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setErrors((prev) => ({ ...prev, email: undefined }));
}}
placeholder={t('userManagement.emailPlaceholder')}
disabled={isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => {
setRoleId(e.target.value ? Number(e.target.value) : '');
setErrors((prev) => ({ ...prev, role: undefined }));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
{errors.role && (
<p className="mt-1 text-sm text-red-600">{errors.role}</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Mail className="w-4 h-4" />}
>
{t('userManagement.sendInvitation')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// Modal component for editing users
interface EditUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (userId: number, roleId: number) => void;
user: AdminUser | null;
roles: AdminRole[];
isLoading: boolean;
}
const EditUserModal: React.FC<EditUserModalProps> = ({
isOpen,
onClose,
onSubmit,
user,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [roleId, setRoleId] = useState<number | ''>('');
React.useEffect(() => {
if (user?.roleId) {
setRoleId(user.roleId);
}
}, [user]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!user || !roleId) return;
onSubmit(user.id, roleId as number);
};
const handleClose = () => {
setRoleId('');
onClose();
};
if (!isOpen || !user) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.editUser')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-4 p-3 bg-neutral-50 rounded-lg">
<p className="text-sm text-neutral-600">
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
</p>
<p className="text-sm text-neutral-500">{user.email}</p>
</div>
<form onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Edit className="w-4 h-4" />}
>
{t('userManagement.saveChanges')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// Confirmation dialog component
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText: string;
isLoading: boolean;
variant?: 'danger' | 'warning';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
isLoading,
variant = 'danger',
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div
className={`p-2 rounded-full ${
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
}`}
>
<AlertTriangle
className={`w-5 h-5 ${
variant === 'danger' ? 'text-red-600' : 'text-amber-600'
}`}
/>
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900">{title}</h2>
<p className="text-sm text-neutral-600 mt-1">{message}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
className={
variant === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: ''
}
>
{confirmText}
</Button>
</div>
</div>
</Card>
</div>
);
};
export const UserManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [activeTab, setActiveTab] = useState<TabType>('users');
const [searchTerm, setSearchTerm] = useState('');
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
type: 'deactivate' | 'cancelInvitation';
id: number;
name: string;
} | null>(null);
// Queries
const {
data: users,
isLoading: usersLoading,
error: usersError,
} = useQuery({
queryKey: ['admin-users'],
queryFn: userManagementService.getUsers,
});
const {
data: roles,
isLoading: rolesLoading,
} = useQuery({
queryKey: ['admin-roles'],
queryFn: userManagementService.getRoles,
});
const {
data: invitations,
isLoading: invitationsLoading,
error: invitationsError,
} = useQuery({
queryKey: ['admin-invitations'],
queryFn: userManagementService.getInvitations,
});
// Mutations
const createInvitationMutation = useMutation({
mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
userManagementService.createInvitation({ email, role_id: roleId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setShowCreateInvitationModal(false);
toast.success(t('userManagement.invitationSent'));
},
onError: (error: Error) => {
toast.error(error.message || t('userManagement.invitationError'));
},
});
const cancelInvitationMutation = useMutation({
mutationFn: userManagementService.cancelInvitation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setConfirmDialog(null);
toast.success(t('userManagement.invitationCancelled'));
},
onError: () => {
toast.error(t('userManagement.cancelInvitationError'));
},
});
const updateUserMutation = useMutation({
mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
userManagementService.updateUser(id, { roleId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setShowEditUserModal(false);
setSelectedUser(null);
toast.success(t('userManagement.userUpdated'));
},
onError: () => {
toast.error(t('userManagement.updateUserError'));
},
});
const deactivateUserMutation = useMutation({
mutationFn: userManagementService.deactivateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userDeactivated'));
},
onError: () => {
toast.error(t('userManagement.deactivateUserError'));
},
});
// Filtered data
const filteredUsers = useMemo(() => {
if (!users) return [];
if (!searchTerm) return users;
const term = searchTerm.toLowerCase();
return users.filter(
(user) =>
user.username.toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term) ||
user.roleName?.toLowerCase().includes(term)
);
}, [users, searchTerm]);
const filteredInvitations = useMemo(() => {
if (!invitations) return [];
if (!searchTerm) return invitations;
const term = searchTerm.toLowerCase();
return invitations.filter(
(invitation) =>
invitation.email.toLowerCase().includes(term) ||
invitation.roleName?.toLowerCase().includes(term)
);
}, [invitations, searchTerm]);
// Handlers
const handleCreateInvitation = (email: string, roleId: number) => {
createInvitationMutation.mutate({ email, roleId });
};
const handleEditUser = (user: AdminUser) => {
setSelectedUser(user);
setShowEditUserModal(true);
};
const handleUpdateUser = (userId: number, roleId: number) => {
updateUserMutation.mutate({ id: userId, roleId });
};
const handleDeactivateUser = (user: AdminUser) => {
setConfirmDialog({
isOpen: true,
type: 'deactivate',
id: user.id,
name: user.username,
});
};
const handleCancelInvitation = (invitation: AdminInvitation) => {
setConfirmDialog({
isOpen: true,
type: 'cancelInvitation',
id: invitation.id,
name: invitation.email,
});
};
const handleConfirmAction = () => {
if (!confirmDialog) return;
if (confirmDialog.type === 'deactivate') {
deactivateUserMutation.mutate(confirmDialog.id);
} else if (confirmDialog.type === 'cancelInvitation') {
cancelInvitationMutation.mutate(confirmDialog.id);
}
};
// Loading state
const isLoading = usersLoading || rolesLoading || invitationsLoading;
if (isLoading) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('userManagement.loading')} />
</div>
</div>
);
}
// Error state
if (usersError || invitationsError) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="text-center py-12">
<p className="text-red-600">{t('userManagement.loadError')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
{t('common.tryAgain')}
</Button>
</div>
</div>
);
}
const tabs: { key: TabType; label: string; count: number }[] = [
{ key: 'users', label: t('userManagement.tabs.users'), count: users?.length || 0 },
{
key: 'invitations',
label: t('userManagement.tabs.invitations'),
count: invitations?.length || 0,
},
];
return (
<div>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => setShowCreateInvitationModal(true)}
>
{t('userManagement.inviteUser')}
</Button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.totalUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.length || 0}
</p>
</div>
<Users className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.activeUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => u.isActive).length || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.pendingInvitations')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{invitations?.length || 0}
</p>
</div>
<Mail className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.inactiveUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => !u.isActive).length || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-neutral-400" />
</div>
</Card>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 mb-6">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
activeTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{tab.label}
<span
className={`px-2 py-0.5 text-xs rounded-full ${
activeTab === tab.key
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-600'
}`}
>
{tab.count}
</span>
</button>
))}
</nav>
</div>
{/* Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="text"
placeholder={
activeTab === 'users'
? t('userManagement.searchUsersPlaceholder')
: t('userManagement.searchInvitationsPlaceholder')
}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</Card>
{/* Users Tab Content */}
{activeTab === 'users' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.lastLogin')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noUsersFound')
: t('userManagement.noUsers')}
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<span className="text-primary-700 font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{user.username}
</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
user.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{user.roleDisplayName || user.roleName || t('userManagement.noRole')}
</span>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-500'
}`}
>
{user.isActive
? t('userManagement.status.active')
: t('userManagement.status.inactive')}
</span>
</td>
<td className="px-6 py-4">
{user.lastLogin ? (
<div className="flex items-center gap-1 text-sm text-neutral-600">
<Clock className="w-4 h-4" />
{formatDistanceToNow(parseISO(user.lastLogin), {
addSuffix: true,
})}
</div>
) : (
<span className="text-sm text-neutral-400">
{t('userManagement.neverLoggedIn')}
</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEditUser(user)}
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title={t('userManagement.editUser')}
>
<Edit className="w-4 h-4" />
</button>
{user.isActive && (
<button
onClick={() => handleDeactivateUser(user)}
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={t('userManagement.deactivateUser')}
>
<UserX className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Invitations Tab Content */}
{activeTab === 'invitations' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.invitedBy')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.expires')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredInvitations.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noInvitationsFound')
: t('userManagement.noInvitations')}
</td>
</tr>
) : (
filteredInvitations.map((invitation) => {
const isExpired = isPast(parseISO(invitation.expiresAt));
return (
<tr key={invitation.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<p className="text-sm font-medium text-neutral-900">
{invitation.email}
</p>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
invitation.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{invitation.roleName}
</span>
</td>
<td className="px-6 py-4 text-sm text-neutral-600">
{invitation.invitedBy || '-'}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 text-sm ${
isExpired ? 'text-red-600' : 'text-neutral-600'
}`}
>
<Clock className="w-4 h-4" />
{isExpired
? t('userManagement.expired')
: formatDistanceToNow(parseISO(invitation.expiresAt), {
addSuffix: true,
})}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleCancelInvitation(invitation)}
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={t('userManagement.cancelInvitation')}
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Create Invitation Modal */}
<CreateInvitationModal
isOpen={showCreateInvitationModal}
onClose={() => setShowCreateInvitationModal(false)}
onSubmit={handleCreateInvitation}
roles={roles || []}
isLoading={createInvitationMutation.isPending}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={showEditUserModal}
onClose={() => {
setShowEditUserModal(false);
setSelectedUser(null);
}}
onSubmit={handleUpdateUser}
user={selectedUser}
roles={roles || []}
isLoading={updateUserMutation.isPending}
/>
{/* Confirmation Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog(null)}
onConfirm={handleConfirmAction}
title={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.title')
: t('userManagement.confirmCancelInvitation.title')
}
message={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name })
: t('userManagement.confirmCancelInvitation.message', {
email: confirmDialog.name,
})
}
confirmText={
confirmDialog.type === 'deactivate'
? t('userManagement.deactivate')
: t('userManagement.cancel')
}
isLoading={
confirmDialog.type === 'deactivate'
? deactivateUserMutation.isPending
: cancelInvitationMutation.isPending
}
variant={confirmDialog.type === 'deactivate' ? 'danger' : 'warning'}
/>
)}
</div>
);
};
UserManagementPage.displayName = 'UserManagementPage';
+2 -1
View File
@@ -10,4 +10,5 @@ export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';
export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage';
@@ -0,0 +1,559 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
User,
Lock,
Eye,
EyeOff,
AlertCircle,
CheckCircle,
Mail,
Shield,
XCircle
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { api } from '../../config/api';
interface InvitationValidation {
valid: boolean;
email: string;
role: string;
expiresAt: string;
}
interface AcceptInvitePayload {
username: string;
password: string;
}
interface AcceptInviteResponse {
message: string;
email: string;
}
interface PasswordRequirement {
label: string;
met: boolean;
test: (password: string) => boolean;
}
export const AcceptInvitePage: React.FC = () => {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [formData, setFormData] = useState({
username: '',
password: '',
confirmPassword: '',
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
// Validate invitation token
const {
data: invitation,
isLoading: isValidating,
error: validationError,
isError
} = useQuery<InvitationValidation>({
queryKey: ['invitation', token],
queryFn: async () => {
const response = await api.get(`/invite/${token}`);
return response.data;
},
enabled: !!token,
retry: false,
});
// Accept invitation mutation
const acceptMutation = useMutation({
mutationFn: async (payload: AcceptInvitePayload) => {
const response = await api.post<AcceptInviteResponse>(`/invite/${token}`, payload);
return response.data;
},
onSuccess: (data) => {
toast.success(data.message || t('acceptInvitation.success'));
setRedirectCountdown(5);
},
onError: (error: any) => {
const errorMessage = error.response?.data?.error || error.response?.data?.message;
if (error.response?.status === 400) {
// Validation errors
if (error.response?.data?.errors) {
const validationErrors: Record<string, string> = {};
error.response.data.errors.forEach((err: { field: string; message: string }) => {
validationErrors[err.field] = err.message;
});
setErrors(validationErrors);
} else {
toast.error(errorMessage || t('acceptInvitation.validationError'));
}
} else if (error.response?.status === 422) {
toast.error(errorMessage || t('acceptInvitation.validationError'));
} else if (error.response?.status === 404) {
toast.error(t('acceptInvitation.invalidOrExpired'));
} else if (error.response?.status === 409) {
toast.error(errorMessage || t('acceptInvitation.alreadyUsed'));
} else {
toast.error(t('acceptInvitation.generalError'));
}
},
});
// Password requirements
const passwordRequirements: PasswordRequirement[] = useMemo(() => [
{
label: t('acceptInvitation.requirements.minLength'),
met: false,
test: (pwd: string) => pwd.length >= 8,
},
{
label: t('acceptInvitation.requirements.uppercase'),
met: false,
test: (pwd: string) => /[A-Z]/.test(pwd),
},
{
label: t('acceptInvitation.requirements.lowercase'),
met: false,
test: (pwd: string) => /[a-z]/.test(pwd),
},
{
label: t('acceptInvitation.requirements.number'),
met: false,
test: (pwd: string) => /[0-9]/.test(pwd),
},
{
label: t('acceptInvitation.requirements.special'),
met: false,
test: (pwd: string) => /[!@#$%^&*(),.?":{}|<>]/.test(pwd),
},
], [t]);
// Calculate password strength
const passwordStrength = useMemo(() => {
const metCount = passwordRequirements.filter(req => req.test(formData.password)).length;
if (metCount === 0) return { level: 0, label: '', color: '' };
if (metCount <= 2) return { level: 1, label: t('acceptInvitation.strength.weak'), color: 'bg-red-500' };
if (metCount <= 3) return { level: 2, label: t('acceptInvitation.strength.fair'), color: 'bg-yellow-500' };
if (metCount <= 4) return { level: 3, label: t('acceptInvitation.strength.good'), color: 'bg-blue-500' };
return { level: 4, label: t('acceptInvitation.strength.strong'), color: 'bg-green-500' };
}, [formData.password, passwordRequirements, t]);
// Redirect countdown effect
useEffect(() => {
if (redirectCountdown === null) return;
if (redirectCountdown === 0) {
navigate('/admin/login');
return;
}
const timer = setTimeout(() => {
setRedirectCountdown(prev => (prev !== null ? prev - 1 : null));
}, 1000);
return () => clearTimeout(timer);
}, [redirectCountdown, navigate]);
// Validate username
const validateUsername = (username: string): string | null => {
if (!username) {
return t('acceptInvitation.usernameRequired');
}
if (username.length < 3) {
return t('acceptInvitation.usernameTooShort');
}
if (username.length > 50) {
return t('acceptInvitation.usernameTooLong');
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return t('acceptInvitation.usernameInvalid');
}
return null;
};
// Validate form
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
const usernameError = validateUsername(formData.username);
if (usernameError) {
newErrors.username = usernameError;
}
if (!formData.password) {
newErrors.password = t('acceptInvitation.passwordRequired');
} else {
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password));
if (!allRequirementsMet) {
newErrors.password = t('acceptInvitation.passwordRequirements');
}
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = t('acceptInvitation.confirmPasswordRequired');
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = t('acceptInvitation.passwordsDoNotMatch');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
acceptMutation.mutate({
username: formData.username,
password: formData.password,
});
};
const handleInputChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
// Format role for display
const formatRole = (role: string): string => {
const roleKey = `admin.roles.${role}`;
const translated = t(roleKey);
// If translation not found, format the role nicely
if (translated === roleKey) {
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
return translated;
};
// Format expiration date
const formatExpirationDate = (dateString: string): string => {
try {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateString;
}
};
// Loading state
if (isValidating) {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md text-center">
<Loading size="lg" text={t('acceptInvitation.validating')} />
</div>
</div>
);
}
// Error state - invalid or expired token
if (isError || !invitation?.valid) {
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired');
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<Card padding="lg">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 flex items-center justify-center">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.invalidTitle')}
</h1>
<p className="text-neutral-600 mb-6">
{errorMessage}
</p>
<p className="text-sm text-neutral-500 mb-6">
{t('acceptInvitation.contactAdmin')}
</p>
<Button
variant="primary"
onClick={() => navigate('/admin/login')}
>
{t('acceptInvitation.goToLogin')}
</Button>
</div>
</Card>
</div>
</div>
);
}
// Success state - account created
if (acceptMutation.isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<Card padding="lg">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.successTitle')}
</h1>
<p className="text-neutral-600 mb-6">
{t('acceptInvitation.successMessage')}
</p>
<p className="text-sm text-neutral-500 mb-6">
{t('acceptInvitation.redirecting', { seconds: redirectCountdown })}
</p>
<Button
variant="primary"
onClick={() => navigate('/admin/login')}
>
{t('acceptInvitation.goToLoginNow')}
</Button>
</div>
</Card>
</div>
</div>
);
}
// Form state - valid invitation
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src="/picpeak-logo-transparent.png"
alt="PicPeak"
className="w-[180px] h-[130px] object-contain"
/>
</div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
{t('acceptInvitation.title')}
</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('acceptInvitation.subtitle')}
</p>
</div>
{/* Invitation Info Card */}
<Card padding="md" className="mb-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Mail className="w-5 h-5 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-500">{t('acceptInvitation.invitedAs')}</p>
<p className="font-medium text-neutral-900 truncate">{invitation.email}</p>
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<Shield className="w-3 h-3" />
{formatRole(invitation.role)}
</span>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('acceptInvitation.expiresAt', { date: formatExpirationDate(invitation.expiresAt) })}
</p>
</div>
</div>
</Card>
{/* Registration Form */}
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Form Error */}
{errors.form && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.usernameLabel')}
</label>
<Input
id="username"
type="text"
value={formData.username}
onChange={handleInputChange('username')}
error={errors.username}
placeholder={t('acceptInvitation.usernamePlaceholder')}
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
autoComplete="username"
autoFocus
/>
<p className="mt-1 text-xs text-neutral-500">
{t('acceptInvitation.usernameHelp')}
</p>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.passwordLabel')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('acceptInvitation.passwordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{/* Password Strength Indicator */}
{formData.password && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-neutral-500">{t('acceptInvitation.passwordStrength')}</span>
<span className={`text-xs font-medium ${
passwordStrength.level <= 1 ? 'text-red-600' :
passwordStrength.level === 2 ? 'text-yellow-600' :
passwordStrength.level === 3 ? 'text-blue-600' :
'text-green-600'
}`}>
{passwordStrength.label}
</span>
</div>
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${passwordStrength.color}`}
style={{ width: `${(passwordStrength.level / 4) * 100}%` }}
/>
</div>
</div>
)}
{/* Password Requirements */}
<div className="mt-3 space-y-1.5">
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirementsTitle')}</p>
{passwordRequirements.map((req, index) => {
const isMet = req.test(formData.password);
return (
<div key={index} className="flex items-center gap-2">
{isMet ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<div className="w-3.5 h-3.5 rounded-full border border-neutral-300" />
)}
<span className={`text-xs ${isMet ? 'text-green-700' : 'text-neutral-500'}`}>
{req.label}
</span>
</div>
);
})}
</div>
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.confirmPasswordLabel')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('acceptInvitation.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{formData.confirmPassword && formData.password === formData.confirmPassword && (
<div className="flex items-center gap-1.5 mt-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span className="text-xs text-green-700">{t('acceptInvitation.passwordsMatch')}</span>
</div>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
size="lg"
isLoading={acceptMutation.isPending}
className="w-full"
>
{t('acceptInvitation.createAccount')}
</Button>
</form>
</Card>
{/* Footer */}
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('acceptInvitation.alreadyHaveAccount')}{' '}
<a
href="/admin/login"
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{t('acceptInvitation.signIn')}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('adminLogin.poweredBy')}
</p>
</div>
</div>
</div>
);
};
AcceptInvitePage.displayName = 'AcceptInvitePage';
+2 -1
View File
@@ -8,4 +8,5 @@ export { emailService } from './email.service';
export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service';
export { feedbackService } from './feedback.service';
export { userManagementService } from './userManagement.service';
+3 -2
View File
@@ -66,8 +66,9 @@ class PhotosService {
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
}
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | string | null): Promise<AdminPhoto> {
const response = await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
return response.data.photo;
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
@@ -0,0 +1,187 @@
import { api } from '../config/api';
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
// Transform snake_case API response to camelCase for frontend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformUser(user: any): AdminUser {
return {
id: user.id,
username: user.username,
email: user.email,
isActive: user.isActive ?? user.is_active,
lastLogin: user.lastLogin ?? user.last_login,
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
createdAt: user.createdAt ?? user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
roleId: user.roleId ?? user.role_id,
roleName: user.roleName ?? user.role_name,
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
createdByUsername: user.createdByUsername ?? user.created_by_username,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformRole(role: any): AdminRole {
return {
id: role.id,
name: role.name,
displayName: role.displayName ?? role.display_name,
description: role.description,
isSystem: role.isSystem ?? role.is_system,
priority: role.priority,
};
}
interface GetUsersResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users: any[];
}
interface GetUserResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user: any;
}
interface GetRolesResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
roles: any[];
}
interface GetInvitationsResponse {
invitations: AdminInvitation[];
}
interface CreateInvitationData {
email: string;
role_id: number;
}
interface CreateInvitationResponse {
invitation: AdminInvitation;
message: string;
}
interface UpdateUserData {
roleId?: number;
isActive?: boolean;
}
interface UpdateUserResponse {
user: AdminUser;
}
interface DeactivateUserResponse {
message: string;
}
interface ResetPasswordResponse {
temporaryPassword: string;
message: string;
}
interface ValidateInvitationResponse {
valid: boolean;
email: string;
roleName: string;
invitedBy: string;
expiresAt: string;
}
interface AcceptInvitationData {
username: string;
password: string;
}
interface AcceptInvitationResponse {
message: string;
user: AdminUser;
}
export const userManagementService = {
/**
* Get all admin users
*/
async getUsers(): Promise<AdminUser[]> {
const response = await api.get<GetUsersResponse>('/admin/users');
return response.data.users.map(transformUser);
},
/**
* Get a single admin user by ID
*/
async getUser(id: number): Promise<AdminUser> {
const response = await api.get<GetUserResponse>(`/admin/users/${id}`);
return transformUser(response.data.user);
},
/**
* Get all available roles
*/
async getRoles(): Promise<AdminRole[]> {
const response = await api.get<GetRolesResponse>('/admin/users/roles');
return response.data.roles.map(transformRole);
},
/**
* Get all pending invitations
*/
async getInvitations(): Promise<AdminInvitation[]> {
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
return response.data.invitations;
},
/**
* Create a new invitation
*/
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
return response.data.invitation;
},
/**
* Cancel a pending invitation
*/
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/users/invitations/${id}`);
},
/**
* Update an admin user
*/
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
return transformUser(response.data.user);
},
/**
* Deactivate an admin user
*/
async deactivateUser(id: number): Promise<string> {
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/deactivate`);
return response.data.message;
},
/**
* Reset an admin user's password
*/
async resetPassword(id: number): Promise<ResetPasswordResponse> {
const response = await api.post<ResetPasswordResponse>(`/admin/users/${id}/reset-password`);
return response.data;
},
/**
* Validate an invitation token (public endpoint)
*/
async validateInvitation(token: string): Promise<ValidateInvitationResponse> {
const response = await api.get<ValidateInvitationResponse>(`/invite/${token}`);
return response.data;
},
/**
* Accept an invitation and create account (public endpoint)
*/
async acceptInvitation(token: string, data: AcceptInvitationData): Promise<AcceptInvitationResponse> {
const response = await api.post<AcceptInvitationResponse>(`/invite/${token}`, data);
return response.data;
},
};
+39
View File
@@ -144,6 +144,18 @@ export interface AdminUser {
username: string;
email: string;
mustChangePassword?: boolean;
role?: {
name: string;
displayName: string;
};
roleId?: number;
roleName?: string;
roleDisplayName?: string;
isActive?: boolean;
lastLogin?: string | null;
lastLoginIp?: string | null;
createdAt?: string;
createdByUsername?: string;
}
export interface LoginResponse {
@@ -178,5 +190,32 @@ export interface ApiError {
}>;
}
// Role and Permission types
export interface AdminRole {
id: number;
name: string;
displayName: string;
description?: string;
isSystem?: boolean;
priority?: number;
}
export interface AdminPermissions {
role: {
name: string;
displayName: string;
} | null;
permissions: string[];
}
export interface AdminInvitation {
id: number;
email: string;
roleName: string;
invitedBy: string;
expiresAt: string;
createdAt: string;
}
// Export protection types
export * from './protection';