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:
+5
-5
@@ -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
|
||||
|
||||
Generated
+50
-61
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './useSessionTimeout';
|
||||
export * from './useOnClickOutside';
|
||||
export * from './useLocalizedDate';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
export * from './useLocalizedTimeAgo';
|
||||
export * from './usePermission';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user