2e495d7c48
Closes #997. Send original files from any event as a token-protected download link, with an optional client-upload channel. Strictly opt-in behind a new `transfers` feature flag, default OFF. Migrations 170-172 (transfers, transfer_files, transfer_extra_files, transfer_uploads, transfer_recipients, transfer_downloads, default settings and two email templates) — all hasTable/hasColumn-guarded and idempotent, with destructive statements confined to down(). Backend: transferService (CRUD, 256-bit download token, 6-char upload token, cross-event ZIP streaming of originals), admin CRUD routes, and two public token routes. transferCleanupService runs an hourly retention sweep; source-event photos are never touched. All three routers fail closed via requireFeatureFlag('transfers'). Review closed two ownership blockers, both the same root cause — permissions used where ownership was needed: - photoIds arrived from the request body and were validated only for existence, so a scoped admin could bundle any event's originals and hand them out through the public download token. filterOwnedPhotoIds now resolves ids to their events and gates them through filterOwnedEventIds, on both the create and add-files paths. - The transfer list was unscoped and carried each row's download token, so any admin with events.view could read another's token and fetch their originals. The list is now scoped by created_by, the token/url fields are stripped from the list payload, and a single router.use('/:id', requireTransferOwnership) covers all twelve /:id routes, 404ing foreign and missing alike. The admin photo picker filters its event list to the same rule, so the UI stops offering picks the API would discard. Fork-PR workflows had not been approved since the fix commits, so the PR's green checks were stale against the pre-fix head. Verified by dispatching tests.yml against the actual head: backend and frontend both green. Follow-up: neither ownership guard has a regression test yet. Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
508 lines
26 KiB
TypeScript
508 lines
26 KiB
TypeScript
import { lazy, Suspense, useEffect, useState } from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { ToastContainer } from 'react-toastify';
|
|
import 'react-toastify/dist/ReactToastify.css';
|
|
import { analyticsService, AnalyticsRouteTracker } from './services/analytics.service';
|
|
|
|
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
|
|
import { ThemeProvider } from './contexts/ThemeContext';
|
|
import { GalleryPage } from './pages/GalleryPage';
|
|
import { ClientAccessPage } from './pages/ClientAccessPage';
|
|
import { PreviewPage } from './pages/gallery/PreviewPage';
|
|
const SlideshowPage = lazy(() => import('./pages/gallery/SlideshowPage').then((m) => ({ default: m.SlideshowPage })));
|
|
import { LegalPage } from './pages/public/LegalPage';
|
|
import {
|
|
AdminLoginPage,
|
|
AdminDashboard,
|
|
EventsListPage,
|
|
CreateEventPage,
|
|
EventDetailsPage,
|
|
EventFeedbackPage,
|
|
ArchivesPage,
|
|
AnalyticsPage,
|
|
SettingsPage,
|
|
SystemHealthPage,
|
|
UserManagementPage,
|
|
CustomerManagementPage,
|
|
CustomerDetailPage,
|
|
WebhookDeliveriesPage,
|
|
// CRM routes (#TBD) — feature-flagged at the route layer via RequireFeature.
|
|
QuotesListPage,
|
|
QuoteEditorPage,
|
|
QuoteDetailPage,
|
|
BillsListPage,
|
|
BillEditorPage,
|
|
BillDetailPage,
|
|
} from './pages/admin';
|
|
import { CrmDevelopmentPage } from './pages/admin/clients/CrmDevelopmentPage';
|
|
import { TaxReportPage } from './pages/admin/clients/TaxReportPage';
|
|
import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
|
|
// E.6 — Calendar page lazy-loaded so the ~200 KB FullCalendar bundle
|
|
// (carved into its own chunk in vite.config.ts) doesn't ship with the
|
|
// main app. Only pages that visit /admin/clients/calendar fetch it.
|
|
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
|
|
const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage })));
|
|
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
|
|
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
|
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
|
import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
|
|
import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage';
|
|
import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage';
|
|
import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage';
|
|
import { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
|
|
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
|
|
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
|
|
import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage';
|
|
import { PaymentCheckPage } from './pages/public/PaymentCheckPage';
|
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
|
import { TransfersPage } from './pages/admin/transfers/TransfersPage';
|
|
import { TransferDownloadPage } from './pages/public/TransferDownloadPage';
|
|
import { TransferUploadPage } from './pages/public/TransferUploadPage';
|
|
import {
|
|
CustomerLoginPage,
|
|
CustomerDashboardPage,
|
|
CustomerAcceptInvitePage,
|
|
CustomerLayout,
|
|
CustomerProfilePage,
|
|
CustomerCalendarPage,
|
|
CustomerQuotesPage,
|
|
CustomerBillsPage,
|
|
CustomerContractsPage,
|
|
CustomerResetPasswordPage,
|
|
} from './pages/customer';
|
|
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
|
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
|
import { ClientsLayout } from './components/admin/ClientsLayout';
|
|
import { AccountingLayout, AccountingIndex } from './components/admin/AccountingLayout';
|
|
import { AccountingInboxPage } from './pages/admin/accounting/AccountingInboxPage';
|
|
import { ExpensesLedgerPage } from './pages/admin/accounting/ExpensesLedgerPage';
|
|
import { RequireFeature } from './components/admin/RequireFeature';
|
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
|
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
|
import { ConfirmDialogProvider } from './components/common';
|
|
import { usePublicSettings } from './hooks/usePublicSettings';
|
|
import { SetupPage } from './pages/SetupPage';
|
|
import { AdminAuthProvider } from './contexts';
|
|
|
|
// Create a client
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
refetchOnWindowFocus: false,
|
|
retry: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Bootstraps the analytics tracker from /public/settings. Lives inside
|
|
// QueryClientProvider so it shares the public-settings cache with every
|
|
// other consumer of usePublicSettings. Dispatches based on the
|
|
// `analytics_tracker_provider` switch (#663 Phase 1) — Umami / Rybbit /
|
|
// Custom / None. Back-compat: when the provider field is missing or unset,
|
|
// falls through to the legacy `umami_enabled`-based behaviour so installs
|
|
// that haven't picked yet keep working.
|
|
function AnalyticsBootstrap() {
|
|
const { data: settings, isError } = usePublicSettings();
|
|
|
|
useEffect(() => {
|
|
if (!settings && !isError) return;
|
|
|
|
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
|
|
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
|
|
const provider = settings?.analytics_tracker_provider;
|
|
|
|
if (provider === 'rybbit' && settings?.rybbit_url && settings.rybbit_website_id) {
|
|
analyticsService.initialize({
|
|
provider: 'rybbit',
|
|
hostUrl: settings.rybbit_url,
|
|
websiteId: settings.rybbit_website_id,
|
|
doNotTrack: true,
|
|
// Mask every /gallery/* path (they embed the share token) so Rybbit's
|
|
// auto-tracked page views never carry the secret (GHSA-7m6c).
|
|
maskPatterns: ['/gallery/**'],
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (provider === 'custom') {
|
|
analyticsService.initialize({
|
|
provider: 'custom',
|
|
customHeadHtml: settings?.analytics_custom_head_html || '',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Umami: explicit provider OR legacy umami_enabled path.
|
|
if (
|
|
(provider === 'umami' || settings?.umami_enabled)
|
|
&& settings?.umami_url && settings?.umami_website_id
|
|
) {
|
|
analyticsService.initialize({
|
|
provider: 'umami',
|
|
hostUrl: settings.umami_url,
|
|
websiteId: settings.umami_website_id,
|
|
// autoTrack omitted → data-auto-track="false": Umami must NOT read the
|
|
// raw window.location (token leak). Page views come from the manual,
|
|
// sanitized AnalyticsRouteTracker instead (GHSA-7m6c).
|
|
doNotTrack: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Env-var fallback (legacy deploys). Only when no DB config and
|
|
// analytics aren't disabled at the public-site level.
|
|
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
|
|
analyticsService.initialize({
|
|
provider: 'umami',
|
|
hostUrl: envUmamiUrl,
|
|
websiteId: envUmamiWebsiteId,
|
|
// autoTrack omitted → data-auto-track="false" (see above, GHSA-7m6c).
|
|
doNotTrack: true,
|
|
});
|
|
}
|
|
}, [settings, isError]);
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Backward-compat redirect for /admin/customers/:id → /admin/clients/accounts/:id.
|
|
* Needed because <Navigate to="..."> can't interpolate route params and we
|
|
* want stale bookmarks / email links to keep working after the Clients
|
|
* section reorg.
|
|
*/
|
|
function RedirectCustomerDetail() {
|
|
const { id } = useParams();
|
|
return <Navigate to={`/admin/clients/accounts/${id}`} replace />;
|
|
}
|
|
|
|
function App() {
|
|
// Track dark mode for toast theming
|
|
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
|
|
|
useEffect(() => {
|
|
const observer = new MutationObserver(() => {
|
|
setToastTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
|
});
|
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<PageErrorBoundary>
|
|
<QueryClientProvider client={queryClient}>
|
|
<AnalyticsBootstrap />
|
|
<MaintenanceProvider>
|
|
<ThemeProvider>
|
|
<GlobalThemeProvider>
|
|
<ConfirmDialogProvider>
|
|
<DynamicFavicon />
|
|
<RobotsMetaTags />
|
|
<Router>
|
|
<AnalyticsRouteTracker />
|
|
<MaintenanceWrapper>
|
|
<SkipLink />
|
|
<Routes>
|
|
{/* Public gallery routes */}
|
|
<Route path="/gallery/preview" element={<PreviewPage />} />
|
|
{/* Live Slideshow ("Diashow") — token-only fullscreen kiosk.
|
|
Self-manages its session token; no GalleryAuthProvider. */}
|
|
<Route path="/gallery/:slug/show/:token" element={
|
|
<Suspense fallback={<Loading />}>
|
|
<SlideshowPage />
|
|
</Suspense>
|
|
} />
|
|
<Route path="/gallery/:slug/client-access" element={
|
|
<GalleryAuthProvider>
|
|
<ClientAccessPage />
|
|
</GalleryAuthProvider>
|
|
} />
|
|
<Route path="/gallery/:slug/:token?" element={
|
|
<GalleryAuthProvider>
|
|
<GalleryPage />
|
|
</GalleryAuthProvider>
|
|
} />
|
|
|
|
{/* First-run setup — public, self-closes once an admin exists */}
|
|
<Route path="/setup" element={
|
|
<AdminAuthProvider>
|
|
<SetupPage />
|
|
</AdminAuthProvider>
|
|
} />
|
|
|
|
{/* Admin routes - wrap with AdminAuthProvider */}
|
|
<Route path="/admin" element={<AdminAuthWrapper />}>
|
|
<Route path="login" element={<AdminLoginPage />} />
|
|
<Route element={<AdminLayout />}>
|
|
<Route path="dashboard" element={<AdminDashboard />} />
|
|
<Route path="events" element={<EventsListPage />} />
|
|
<Route path="events/new" element={<CreateEventPage />} />
|
|
<Route path="events/:id" element={<EventDetailsPage />} />
|
|
<Route path="events/:id/feedback" element={<EventFeedbackPage />} />
|
|
<Route path="archives" element={<ArchivesPage />} />
|
|
{/* PicTransfer (#997) — cross-event file transfers.
|
|
Gated by the `transfers` flag (strictly opt-in). */}
|
|
<Route element={<RequireFeature flag="transfers" />}>
|
|
<Route path="transfers" element={<TransfersPage />} />
|
|
</Route>
|
|
|
|
{/* Feature-gated surfaces — redirect to /admin/dashboard when flag is off. */}
|
|
<Route element={<RequireFeature flag="analytics" />}>
|
|
<Route path="analytics" element={<AnalyticsPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature flag="userManagement" />}>
|
|
<Route path="users" element={<UserManagementPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature flag="messaging" />}>
|
|
<Route path="messages" element={
|
|
<Suspense fallback={<Loading />}>
|
|
<MessagesPage />
|
|
</Suspense>
|
|
} />
|
|
</Route>
|
|
{/* Clients section (#354 follow-up). Parent route
|
|
gated by the top-level `clients` flag — when off
|
|
the sidebar entry is hidden and every /admin/clients/*
|
|
URL redirects to /admin/dashboard. Inside, the
|
|
ClientsLayout renders a Settings-style sub-nav
|
|
and the active sub-feature's page through an
|
|
Outlet. Each sub-route is feature-flagged
|
|
independently. */}
|
|
<Route element={<RequireFeature flag="clients" />}>
|
|
<Route path="clients" element={<ClientsLayout />}>
|
|
<Route element={<RequireFeature flag="customerPortal" />}>
|
|
<Route path="accounts" element={<CustomerManagementPage />} />
|
|
<Route path="accounts/:id" element={<CustomerDetailPage />} />
|
|
</Route>
|
|
{/* Quotes (CRM) — gated by `quotes`. */}
|
|
<Route element={<RequireFeature flag="quotes" />}>
|
|
<Route path="quotes" element={<QuotesListPage />} />
|
|
<Route path="quotes/new" element={<QuoteEditorPage />} />
|
|
<Route path="quotes/:id" element={<QuoteDetailPage />} />
|
|
<Route path="quotes/:id/edit" element={<QuoteEditorPage />} />
|
|
</Route>
|
|
{/* Project Overview (CRM) — admin-only grouping
|
|
layer above events, gated by `projects`. */}
|
|
<Route element={<RequireFeature flag="projects" />}>
|
|
<Route path="projects" element={<ProjectsListPage />} />
|
|
<Route path="projects/:id" element={<ProjectCockpitPage />} />
|
|
</Route>
|
|
{/* Bills / invoices (CRM) — gated by `bills`. */}
|
|
<Route element={<RequireFeature flag="bills" />}>
|
|
<Route path="bills" element={<BillsListPage />} />
|
|
<Route path="bills/new" element={<BillEditorPage />} />
|
|
<Route path="bills/:id" element={<BillDetailPage />} />
|
|
<Route path="bills/:id/edit" element={<BillEditorPage />} />
|
|
</Route>
|
|
|
|
{/* Contracts (CRM) — gated by `contracts`. Independent
|
|
of quotes/bills; a free-standing legal document
|
|
type composed from a library of reusable blocks. */}
|
|
<Route element={<RequireFeature flag="contracts" />}>
|
|
<Route path="contracts" element={<ContractsListPage />} />
|
|
<Route path="contracts/new" element={<ContractEditorPage />} />
|
|
<Route path="contracts/blocks" element={<BlockLibraryPage />} />
|
|
<Route path="contracts/:id" element={<ContractDetailPage />} />
|
|
<Route path="contracts/:id/edit" element={<ContractEditorPage />} />
|
|
</Route>
|
|
|
|
{/* Hour logging (standalone surface) — gated by
|
|
`hoursLogging`. Independent of `bills` so admin
|
|
can log hours before the full billing surface
|
|
is enabled. */}
|
|
<Route element={<RequireFeature flag="hoursLogging" />}>
|
|
<Route path="hours" element={<HoursLoggingPage />} />
|
|
</Route>
|
|
|
|
{/* Admin calendar (migration 137) — gated by
|
|
`calendar`. Lazy-loaded so the FullCalendar
|
|
bundle stays out of the main chunk. */}
|
|
<Route element={<RequireFeature flag="calendar" />}>
|
|
<Route
|
|
path="calendar"
|
|
element={
|
|
<Suspense fallback={<Loading />}>
|
|
<CalendarPage />
|
|
</Suspense>
|
|
}
|
|
/>
|
|
</Route>
|
|
|
|
{/* Tax export moved permanently to the Accounting
|
|
section. Keep this path as a redirect so old
|
|
bookmarks / links don't 404. */}
|
|
<Route path="tax-report" element={<Navigate to="/admin/accounting/tax-report" replace />} />
|
|
{/* Developer tools — gated by `crmDevelopment`. */}
|
|
<Route element={<RequireFeature flag="crmDevelopment" />}>
|
|
<Route path="development" element={<CrmDevelopmentPage />} />
|
|
</Route>
|
|
{/* Default: send /admin/clients (no sub-path) to
|
|
the first enabled sub-feature. accounts comes
|
|
first because it predates the others. The empty
|
|
state inside ClientsLayout handles "parent on,
|
|
all children off". */}
|
|
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Accounting section (migration 122). Parent gated by
|
|
the `accounting` flag. Hosts the Tax report — which
|
|
relocates here from the CRM sub-nav when accounting
|
|
is on — plus the future inbound-invoice / expenses
|
|
pages. Each sub-route is independently flagged. */}
|
|
<Route element={<RequireFeature flag="accounting" />}>
|
|
<Route path="accounting" element={<AccountingLayout />}>
|
|
<Route element={<RequireFeature flag="incomingInvoices" />}>
|
|
<Route path="inbox" element={<AccountingInboxPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature flag="expenses" />}>
|
|
<Route path="expenses" element={<ExpensesLedgerPage />} />
|
|
</Route>
|
|
<Route element={<RequireFeature flag="taxReport" />}>
|
|
<Route path="tax-report" element={<TaxReportPage />} />
|
|
{/* Treuhänder export moved onto the Tax page; keep
|
|
the old path working for bookmarks. */}
|
|
<Route path="export" element={<Navigate to="/admin/accounting/tax-report" replace />} />
|
|
</Route>
|
|
{/* Chart of accounts (Layer A) moved into Settings →
|
|
Accounting; keep the old path working for bookmarks. */}
|
|
<Route path="ledger" element={<Navigate to="/admin/settings?tab=accounting" replace />} />
|
|
<Route index element={<AccountingIndex />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Old /admin/customers paths now live under
|
|
/admin/clients/accounts. Kept indefinitely as
|
|
redirects so existing bookmarks and email links
|
|
don't 404. */}
|
|
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
|
|
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
|
|
|
|
{/* Workflows (automation engine) — top-level area gated
|
|
by the `workflows` flag. */}
|
|
<Route element={<RequireFeature flag="workflows" />}>
|
|
<Route path="workflows" element={<WorkflowsListPage />} />
|
|
<Route path="workflows/approvals" element={<WorkflowApprovalsPage />} />
|
|
<Route path="workflows/:id" element={<WorkflowEditorPage />} />
|
|
</Route>
|
|
|
|
<Route path="settings" element={<SettingsPage />} />
|
|
<Route path="system-health" element={<SystemHealthPage />} />
|
|
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
|
|
|
{/* Old top-level routes — these surfaces now live as
|
|
Settings tabs (#feature-flags-settings-reorg).
|
|
Kept indefinitely as redirects so existing bookmarks
|
|
and external links don't 404. */}
|
|
<Route path="email" element={<Navigate to="/admin/settings?tab=email" replace />} />
|
|
<Route path="branding" element={<Navigate to="/admin/settings?tab=branding" replace />} />
|
|
<Route path="event-types" element={<Navigate to="/admin/settings?tab=eventTypes" replace />} />
|
|
<Route path="backup" element={<Navigate to="/admin/settings?tab=backup" replace />} />
|
|
<Route path="cms" element={<Navigate to="/admin/settings?tab=cms" replace />} />
|
|
|
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
|
</Route>
|
|
</Route>
|
|
|
|
{/* Public invitation acceptance page */}
|
|
<Route path="/invite/:token" element={<AcceptInvitePage />} />
|
|
|
|
{/* Public quote accept/decline page (CRM). Token-only,
|
|
no auth required. */}
|
|
<Route path="/quote/:token" element={<QuoteResponsePage />} />
|
|
<Route path="/contract/:token" element={<ContractResponsePage />} />
|
|
|
|
{/* Admin payment-check page (CRM) — token only,
|
|
no auth. Reached from the "Paid in full /
|
|
Partial / Not paid" buttons in the payment-
|
|
check email. */}
|
|
<Route path="/payment-check/:token" element={<PaymentCheckPage />} />
|
|
|
|
{/* PicTransfer (#997) — recipient download + client upload,
|
|
token-only, no auth. */}
|
|
<Route path="/transfer/:token" element={<TransferDownloadPage />} />
|
|
<Route path="/transfer-upload/:token" element={<TransferUploadPage />} />
|
|
|
|
{/* Customer surface (#354). Strictly separate provider /
|
|
cookie / API surface from /admin/*. The customerPortal
|
|
feature flag hides the *admin-side* surfaces (sidebar
|
|
entry, /admin/customers routes, CustomerAccountPicker)
|
|
via RequireFeature. The customer-side /customer/*
|
|
tree stays publicly reachable so existing customers
|
|
can still log in even if the admin temporarily flips
|
|
the flag off — and because RequireFeature reads from
|
|
FeatureFlagsProvider (admin-only context), gating
|
|
these routes here would crash unauthenticated
|
|
visitors with an unmounted-provider error. */}
|
|
<Route path="/customer/*" element={
|
|
<CustomerAuthProvider>
|
|
<Routes>
|
|
{/* Public surfaces: login, accept-invite, reset —
|
|
no CustomerLayout (their own branded shells). */}
|
|
<Route path="login" element={<CustomerLoginPage />} />
|
|
<Route path="invite/:token" element={<CustomerAcceptInvitePage />} />
|
|
<Route path="reset-password/:token" element={<CustomerResetPasswordPage />} />
|
|
|
|
{/* Authenticated surfaces share the sidebar layout
|
|
(Outlet pattern, mirrors AdminLayout). The
|
|
CustomerLayout itself enforces auth — bouncing
|
|
unauthenticated visitors to /customer/login. */}
|
|
<Route element={<CustomerLayout />}>
|
|
<Route path="dashboard" element={<CustomerDashboardPage />} />
|
|
<Route path="calendar" element={<CustomerCalendarPage />} />
|
|
<Route path="quotes" element={<CustomerQuotesPage />} />
|
|
<Route path="contracts" element={<CustomerContractsPage />} />
|
|
<Route path="bills" element={<CustomerBillsPage />} />
|
|
<Route path="profile" element={<CustomerProfilePage />} />
|
|
</Route>
|
|
|
|
<Route index element={<Navigate to="/customer/dashboard" replace />} />
|
|
</Routes>
|
|
</CustomerAuthProvider>
|
|
} />
|
|
|
|
{/* Public legal pages */}
|
|
<Route path="/impressum" element={<LegalPage />} />
|
|
<Route path="/datenschutz" element={<LegalPage />} />
|
|
<Route path="/:slug" element={<LegalPage />} />
|
|
|
|
{/* Default redirect */}
|
|
<Route path="/" element={<Navigate to="/admin/login" replace />} />
|
|
|
|
{/* Customisable 404 (#324) — caught here for any path that
|
|
didn't match. Top-level `/:slug` is consumed above by
|
|
LegalPage; this picks up deeper unknown paths. */}
|
|
<Route path="*" element={<CMSContentBlock slug="not-found" />} />
|
|
</Routes>
|
|
</MaintenanceWrapper>
|
|
</Router>
|
|
|
|
{/* Offline indicator */}
|
|
<OfflineIndicator />
|
|
|
|
{/* Toast notifications */}
|
|
<ToastContainer
|
|
position="bottom-right"
|
|
autoClose={5000}
|
|
hideProgressBar={false}
|
|
newestOnTop
|
|
closeOnClick
|
|
rtl={false}
|
|
pauseOnFocusLoss
|
|
draggable
|
|
pauseOnHover
|
|
theme={toastTheme}
|
|
/>
|
|
</ConfirmDialogProvider>
|
|
</GlobalThemeProvider>
|
|
</ThemeProvider>
|
|
</MaintenanceProvider>
|
|
</QueryClientProvider>
|
|
</PageErrorBoundary>
|
|
);
|
|
}
|
|
|
|
export default App;
|