diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index 4337bd04..0450592f 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -170,7 +170,7 @@ export const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const { flags, isLoading: flagsLoading } = useFeatureFlags();
- const { hasAnyPermission } = usePermissions();
+ const { hasAnyPermission, isLoading: permissionsLoading } = usePermissions();
// Read ?tab=… on mount; default to Features per the redesign.
const initialTab: TabType = isValidTab(searchParams.get('tab'))
@@ -288,7 +288,11 @@ export const SettingsPage: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flagsLoading, activeTab, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, flags.slideshow]);
- if (isLoading) {
+ // Wait for the permissions context too: on a fresh/hard mount it starts out
+ // empty, which filters every nav group down to nothing and left `activeItem`
+ // undefined below (QA J.08 crash). `activeTab` is held in state, so a
+ // deep-linked ?tab= still lands on the right tab once permissions arrive.
+ if (isLoading || permissionsLoading) {
return (
@@ -483,7 +487,7 @@ export const SettingsPage: React.FC = () => {
- {showSectionHeading && (
+ {showSectionHeading && activeItem && (
{/* Section heading icon stays neutral so the Settings
diff --git a/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx
new file mode 100644
index 00000000..934538de
--- /dev/null
+++ b/frontend/src/pages/admin/__tests__/settingsPageMountRace.test.tsx
@@ -0,0 +1,110 @@
+/**
+ * Settings crashed on a fresh/hard load of any non-default tab (QA J.08).
+ *
+ * The nav groups are permission-filtered, so before PermissionsContext has
+ * resolved every group filters to empty, `allItems[0]` is undefined, and the
+ * section heading's `
` throws. In-app SPA navigation never
+ * hit it because the context was already warm.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+vi.mock('react-i18next', async () => {
+ const actual = await vi.importActual
('react-i18next');
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k) }),
+ };
+});
+
+const flagsState = { flags: {} as Record, isLoading: false };
+vi.mock('../../../contexts/FeatureFlagsContext', () => ({
+ useFeatureFlags: () => flagsState,
+ useFeatureEnabled: () => false,
+}));
+
+const permissionsState = { hasAnyPermission: (_: string[]) => true, isLoading: false };
+vi.mock('../../../contexts/PermissionsContext', () => ({
+ usePermissions: () => permissionsState,
+}));
+
+// The settings barrel pulls in every tab; stub it down to the shell's needs.
+vi.mock('../../../features/settings', () => {
+ const Stub = () => null;
+ return {
+ useSettingsState: () => ({ isLoading: false }),
+ FeaturesTab: Stub,
+ GeneralTab: Stub,
+ EventsTab: Stub,
+ StatusTab: Stub,
+ SecurityTab: Stub,
+ ImageSecurityTab: Stub,
+ CategoriesTab: Stub,
+ AnalyticsTab: Stub,
+ ModerationTab: Stub,
+ StylingTab: Stub,
+ SEOTab: Stub,
+ ThumbnailsTab: Stub,
+ DownloadsTab: Stub,
+ ApiTokensTab: Stub,
+ WebhooksTab: Stub,
+ AccountingTab: Stub,
+ WhatsAppTab: Stub,
+ SsoTab: Stub,
+ };
+});
+
+vi.mock('../EmailConfigPage', () => ({ EmailConfigPage: () => null }));
+vi.mock('../BrandingPage', () => ({ BrandingPage: () => null }));
+vi.mock('../EventTypesPage', () => ({ EventTypesPage: () => null }));
+vi.mock('../SlideshowSettingsPage', () => ({ SlideshowSettingsPage: () => null }));
+vi.mock('../BackupManagement', () => ({ BackupManagement: () => null }));
+vi.mock('../CMSPage', () => ({ CMSPage: () => null }));
+vi.mock('../settings/SettingsBusinessProfilePage', () => ({ SettingsBusinessProfilePage: () => null }));
+vi.mock('../settings/CrmSettingsPage', () => ({ CrmSettingsPage: () => null }));
+vi.mock('../settings/ReminderTemplatesPage', () => ({ ReminderTemplatesPage: () => null }));
+vi.mock('../contracts/BlockLibraryPage', () => ({ BlockLibraryPage: () => null }));
+
+import { SettingsPage } from '../SettingsPage';
+
+function renderAt(tab: string) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('SettingsPage fresh-mount permission race (QA J.08)', () => {
+ beforeEach(() => {
+ permissionsState.hasAnyPermission = () => true;
+ permissionsState.isLoading = false;
+ });
+
+ it('does not crash on a deep-linked tab while permissions are still loading', () => {
+ permissionsState.isLoading = true;
+ permissionsState.hasAnyPermission = () => false;
+
+ expect(() => renderAt('webhooks')).not.toThrow();
+ expect(screen.getByText('settings.loadingSettings')).toBeInTheDocument();
+ });
+
+ it('still lands on the deep-linked tab once permissions arrive', () => {
+ renderAt('webhooks');
+
+ expect(screen.getByRole('heading', { level: 2, name: 'Webhooks' })).toBeInTheDocument();
+ });
+
+ it('does not crash when the role has no settings tab permissions at all', () => {
+ permissionsState.hasAnyPermission = () => false;
+
+ expect(() => renderAt('webhooks')).not.toThrow();
+ expect(screen.getByText('settings.title')).toBeInTheDocument();
+ });
+});