Completes local-dev-master-slave-setup: dual-instance frontend tooling, module-capability gating, and master/slave protocol self-healing fixes

Frontend (Unit 2 completion): dual dev-server tooling (pnpm dev:slave,
pnpm dev:all), per-instance browser tab titles, and a backend
capability check (SystemController + useSystemCapabilities +
ModuleGuard) so a Master-only page is hidden on a slave instance
instead of assuming every backend has every module.

Master/slave protocol fixes surfaced by actually running master and
slave side by side locally:
- Deactivating a CMS instance (Inactive) now releases the slave's
  master gate instead of leaving it stuck on its last pushed status.
- The periodic integrity check now also re-pushes status to every
  reachable slave (previously URL-verification only) and runs once
  immediately on startup.
- Added the originally-specified (but never implemented) slave-pull
  path: a slave now periodically polls its own status from the master
  (GET /api/v1/SlaveStatus) and fails open to Available if the master
  is unreachable for too long, complementing the existing push.
- The slave's own Settings page can no longer "successfully" change
  local availability while the master controls it; it's now locked
  with an explanatory banner and the backend rejects the write with
  409 instead of silently no-op'ing it.
- CMS instance status badges now match the dashboard's color/icon
  styling instead of a plain grey badge.

Also corrected the master-cms-module design docs to match this
as-built behavior, and flagged (without a full rewrite) a larger,
pre-existing divergence between its inception-stage application
design and what construction actually built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:53:52 +02:00
co-authored by Claude Sonnet 5
parent 274946dbff
commit 0447993181
81 changed files with 2191 additions and 86 deletions
+13
View File
@@ -4,3 +4,16 @@
# Set CookieSameSite=None in appsettings.Development.json so the cookie
# is sent cross-origin when the frontend and backend run on different ports.
VITE_API_BASE_URL=https://localhost:7221
# Browser tab title. Defaults to "SlpModularCms" if unset — override so the
# tab is instantly recognizable when running multiple instances side by side
# (e.g. local master vs. slave, see below).
VITE_APP_TITLE=SlpModularCms
# --- Local master/slave dev setup ---
# To point the frontend at the slave instance (SlpModularCms.Api.Slave,
# see root README.md "Lokaal Master + Slave Draaien (Dev)") instead of the
# master, copy this file to `.env.slave.local` with:
# VITE_API_BASE_URL=https://localhost:7222
# VITE_APP_TITLE=SlpModularCms (Slave)
# and run `pnpm dev:slave` (uses --mode slave, port 5174) instead of `pnpm dev`.
+3
View File
@@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:slave": "vite --mode slave --port 5174",
"dev:all": "concurrently -n master,slave -c blue,magenta \"pnpm dev\" \"pnpm dev:slave\"",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
@@ -47,6 +49,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.9",
"concurrently": "^9.1.2",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
+67
View File
@@ -99,6 +99,9 @@ importers:
'@vitest/coverage-v8':
specifier: ^4.1.9
version: 4.1.9(vitest@4.1.9)
concurrently:
specifier: ^9.1.2
version: 9.2.3
eslint:
specifier: ^10.3.0
version: 10.5.0(jiti@2.7.0)
@@ -1252,6 +1255,10 @@ packages:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1274,6 +1281,11 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
concurrently@9.2.3:
resolution: {integrity: sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==}
engines: {node: '>=18'}
hasBin: true
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -1917,6 +1929,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -1954,6 +1969,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -2000,6 +2019,10 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -2047,6 +2070,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -2260,6 +2287,10 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
@@ -3359,6 +3390,11 @@ snapshots:
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -3379,6 +3415,15 @@ snapshots:
color-name@1.1.4: {}
concurrently@9.2.3:
dependencies:
chalk: 4.1.2
rxjs: 7.8.2
shell-quote: 1.8.4
supports-color: 8.1.1
tree-kill: 1.2.2
yargs: 17.7.2
convert-source-map@2.0.0: {}
cookie-es@3.1.1: {}
@@ -3965,6 +4010,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -3989,6 +4038,8 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.4: {}
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
@@ -4026,6 +4077,10 @@ snapshots:
dependencies:
has-flag: 4.0.0
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
symbol-tree@3.2.4: {}
tagged-tag@1.0.0: {}
@@ -4061,6 +4116,8 @@ snapshots:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -4209,6 +4266,16 @@ snapshots:
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@17.7.3:
dependencies:
cliui: 8.0.1
+9
View File
@@ -115,4 +115,13 @@ export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
message: string;
/** True when a Master CMS has taken control of this instance's gate — the local status cannot be changed here. */
isMasterControlled: boolean;
}
// Which optional modules (e.g. "Master") this backend instance has loaded —
// lets the frontend tell a master-only feature apart from a slave instance
// without that module (local master/slave dev setup).
export interface SystemCapabilities {
modules: string[];
}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { SystemCapabilities } from './types';
// Backend may return PascalCase (Modules) if no camelCase policy is set.
type RawSystemCapabilities = { modules?: string[]; Modules?: string[] };
export function useSystemCapabilities() {
return useQuery<SystemCapabilities, Error>({
queryKey: ['system', 'capabilities'],
queryFn: async () => {
const raw = await api.get<RawSystemCapabilities>('/api/v1/System/capabilities');
return { modules: raw.modules ?? raw.Modules ?? [] };
},
// Which modules a backend has loaded is fixed for the lifetime of that
// backend process — no need to ever refetch within a session.
staleTime: Infinity,
});
}
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
interface ModuleGuardProps {
requiredModule: string;
children: ReactNode;
}
/**
* Hides a feature that only exists on backend instances with a given module loaded
* (e.g. the CMS-instance management page requires Modules.Master a local slave
* instance without it should not expose this page even to an Owner).
*/
export function ModuleGuard({ requiredModule, children }: ModuleGuardProps) {
const { t } = useTranslation();
const { data: capabilities, isPending } = useSystemCapabilities();
if (isPending) {
return (
<div className="flex min-h-40 items-center justify-center text-muted-foreground">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (capabilities?.modules.includes(requiredModule)) {
return <>{children}</>;
}
return (
<div
data-testid="feature-unavailable-message"
className="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<h1 className="text-2xl font-semibold">{t('errors.featureUnavailableTitle')}</h1>
<p className="max-w-sm text-sm text-muted-foreground">{t('errors.featureUnavailable')}</p>
<Link to="/dashboard" className="text-sm text-primary underline-offset-4 hover:underline">
{t('nav.dashboard')}
</Link>
</div>
);
}
+28 -13
View File
@@ -1,6 +1,5 @@
import { useTranslation } from 'react-i18next';
import { MoreHorizontal } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { MoreHorizontal, CheckCircle, XCircle, MinusCircle } from 'lucide-react';
import {
Table,
TableBody,
@@ -18,11 +17,23 @@ import {
import { Button } from '@/components/ui/button';
import type { CmsInstance, CmsInstanceStatus } from '@/api/types';
function statusBadgeVariant(status: CmsInstanceStatus) {
if (status === 'Available') return 'secondary';
if (status === 'NotAvailable') return 'destructive';
return 'outline';
}
const STATUS_BADGE_CONFIG: Record<
CmsInstanceStatus,
{ colorClass: string; Icon: React.ComponentType<{ className?: string }> }
> = {
Available: {
colorClass: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
Icon: CheckCircle,
},
NotAvailable: {
colorClass: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
Icon: XCircle,
},
Inactive: {
colorClass: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300',
Icon: MinusCircle,
},
};
function formatDate(iso: string | null): string {
if (!iso) return '—';
@@ -50,7 +61,9 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
</TableRow>
</TableHeader>
<TableBody>
{instances.map((instance) => (
{instances.map((instance) => {
const { colorClass, Icon } = STATUS_BADGE_CONFIG[instance.status];
return (
<TableRow
key={instance.id}
data-testid="cms-instance-row"
@@ -59,12 +72,13 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
<TableCell>{instance.name}</TableCell>
<TableCell>{instance.url}</TableCell>
<TableCell>
<Badge
variant={statusBadgeVariant(instance.status)}
<div
data-testid="cms-instance-status-badge"
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-sm font-medium ${colorClass}`}
>
{t(`cms.status.${instance.status}`)}
</Badge>
<Icon className="size-4" />
<span>{t(`cms.status.${instance.status}`)}</span>
</div>
</TableCell>
<TableCell>{formatDate(instance.lastContactedAt)}</TableCell>
<TableCell>{instance.disableMessage ?? '—'}</TableCell>
@@ -90,7 +104,8 @@ export function CmsInstanceList({ instances, onSetStatus }: CmsInstanceListProps
</DropdownMenu>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
);
@@ -27,7 +27,8 @@ describe('Sidebar role filtering (BR-U3-01 BR-U3-06)', () => {
expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
expect(screen.getByTestId('nav-cms')).toBeInTheDocument();
// CMS nav item also waits on the async system-capabilities check (Master module presence).
expect(await screen.findByTestId('nav-cms')).toBeInTheDocument();
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
});
@@ -41,6 +42,23 @@ describe('Sidebar role filtering (BR-U3-01 BR-U3-06)', () => {
expect(screen.queryByTestId('nav-profile')).not.toBeInTheDocument();
});
it('Owner does not see CMS when the backend has no Master module (slave instance)', async () => {
mockAuthenticatedAs('Owner');
server.use(
http.get('*/System/capabilities', () =>
HttpResponse.json({ modules: ['Availability', 'Identity'] }),
),
);
renderApp('/dashboard');
expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument();
expect(screen.getByTestId('nav-users')).toBeInTheDocument();
expect(screen.getByTestId('nav-settings')).toBeInTheDocument();
// Let the async system-capabilities query settle before asserting its absence,
// otherwise this would also pass trivially during the loading state.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument();
});
it('User sees Dashboard only', async () => {
mockAuthenticatedAs('User');
renderApp('/dashboard');
+7 -2
View File
@@ -5,6 +5,7 @@ import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/auth-context';
import { useAvailabilityStatus } from '@/api/useAvailability';
import { useSystemCapabilities } from '@/api/useSystemCapabilities';
import { LanguageSwitcher } from '@/i18n/LanguageSwitcher';
import { ThemeToggle } from './ThemeToggle';
import { UserMenu } from './UserMenu';
@@ -17,6 +18,7 @@ interface NavItem {
icon: LucideIcon;
testId: string;
roles?: Role[];
requiredModule?: string;
}
const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
@@ -24,7 +26,7 @@ const ALLOWED_WHEN_UNAVAILABLE = ['/dashboard', '/settings'];
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' },
{ to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] },
{ to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'], requiredModule: 'Master' },
];
const SETTINGS_ITEM: NavItem = {
@@ -39,11 +41,14 @@ export function Sidebar({ onClose }: SidebarProps = {}) {
const { t } = useTranslation();
const { user } = useAuth();
const { data: availability } = useAvailabilityStatus();
const { data: capabilities } = useSystemCapabilities();
const role = user?.role as Role | undefined;
const systemUnavailable = availability?.status === 'NotAvailable';
const visibleItems = NAV_ITEMS.filter(
(item) => !item.roles || (role && item.roles.includes(role))
(item) =>
(!item.roles || (role && item.roles.includes(role))) &&
(!item.requiredModule || capabilities?.modules.includes(item.requiredModule))
);
const showSettings = !SETTINGS_ITEM.roles || (role && SETTINGS_ITEM.roles.includes(role));
@@ -177,7 +177,10 @@
"Available": "Available",
"Maintenance": "Maintenance",
"NotAvailable": "Unavailable"
}
},
"masterControlledTitle": "Controlled by Master CMS",
"masterControlled": "This instance has been disabled by the Master CMS. Availability cannot be changed here until the Master releases it.",
"masterControlledSaveError": "Cannot be changed: the Master CMS controls this status."
},
"modules": { "title": "Module Management", "comingSoon": "Coming soon" },
"systemConfig": { "title": "System Configuration", "comingSoon": "Coming soon" },
@@ -243,6 +246,8 @@
"generic": "Something went wrong. Please try again.",
"accessDenied": "You do not have permission to access this page.",
"setupRequired": "System setup required. Please initialize the system first.",
"invalidInvitationToken": "Invalid or expired invitation token."
"invalidInvitationToken": "Invalid or expired invitation token.",
"featureUnavailableTitle": "Not available on this instance",
"featureUnavailable": "This feature is only available on a Master CMS instance."
}
}
@@ -177,7 +177,10 @@
"Available": "Beschikbaar",
"Maintenance": "Onderhoud",
"NotAvailable": "Niet beschikbaar"
}
},
"masterControlledTitle": "Beheerd door Master-CMS",
"masterControlled": "Deze instantie is door de Master-CMS uitgeschakeld. De beschikbaarheid kan hier niet worden gewijzigd totdat de Master de status weer vrijgeeft.",
"masterControlledSaveError": "Kan niet worden gewijzigd: de Master-CMS beheert deze status."
},
"modules": { "title": "Modulebeheer", "comingSoon": "Binnenkort beschikbaar" },
"systemConfig": { "title": "Systeemconfiguratie", "comingSoon": "Binnenkort beschikbaar" },
@@ -243,6 +246,8 @@
"generic": "Er is iets misgegaan. Probeer het opnieuw.",
"accessDenied": "Je hebt geen toestemming om deze pagina te bekijken.",
"setupRequired": "Systeeminstallatie vereist. Initialiseer eerst het systeem.",
"invalidInvitationToken": "Ongeldig of verlopen uitnodigingstoken."
"invalidInvitationToken": "Ongeldig of verlopen uitnodigingstoken.",
"featureUnavailableTitle": "Niet beschikbaar op deze instantie",
"featureUnavailable": "Deze functie is alleen beschikbaar op een Master-CMS-instantie."
}
}
+2
View File
@@ -7,6 +7,7 @@ import { z } from 'zod';
*/
const configSchema = z.object({
apiBaseUrl: z.string().url(),
appTitle: z.string(),
});
export type AppConfig = z.infer<typeof configSchema>;
@@ -20,6 +21,7 @@ export function getAppConfig(): AppConfig {
const raw: AppConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
appTitle: import.meta.env.VITE_APP_TITLE ?? 'SlpModularCms',
};
if (import.meta.env.DEV) {
+3
View File
@@ -8,6 +8,9 @@ import { AuthProvider } from '@/contexts/AuthProvider';
import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { getAppConfig } from '@/lib/config';
document.title = getAppConfig().appTitle;
const queryClient = new QueryClient({
defaultOptions: {
@@ -6,6 +6,7 @@ export const availabilityHandlers = [
status: 'Available',
checkedAt: new Date().toISOString(),
message: '',
isMasterControlled: false,
}),
),
+3 -1
View File
@@ -4,9 +4,10 @@ import { setupHandlers } from './setup/handlers';
import { invitationHandlers } from './invitation/handlers';
import { availabilityHandlers } from './availability/handlers';
import { cmsHandlers } from './cms/handlers';
import { systemHandlers } from './system/handlers';
/** All default MSW handlers, composed from feature folders (Q3-B). */
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers];
export const handlers = [...authHandlers, ...userHandlers, ...setupHandlers, ...invitationHandlers, ...availabilityHandlers, ...cmsHandlers, ...systemHandlers];
export { authHandlers } from './auth/handlers';
export { userHandlers } from './users/handlers';
@@ -14,4 +15,5 @@ export { setupHandlers, setupUninitializedHandlers, setupConflictHandlers, setup
export { invitationHandlers } from './invitation/handlers';
export { availabilityHandlers } from './availability/handlers';
export { cmsHandlers, resetMockCmsInstances, getMockCmsInstances } from './cms/handlers';
export { systemHandlers } from './system/handlers';
export * from './auth/fixtures';
+9
View File
@@ -0,0 +1,9 @@
import { http, HttpResponse } from 'msw';
export const systemHandlers = [
http.get('*/System/capabilities', () =>
HttpResponse.json({
modules: ['Availability', 'Identity', 'Master'],
}),
),
];
+46
View File
@@ -72,6 +72,52 @@ describe('SettingsPage', () => {
expect(await screen.findByText(/something went wrong/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('disables the availability controls and shows a banner when master-controlled', async () => {
server.use(
http.get(`${API_BASE}/api/v1/Availability/status`, () =>
HttpResponse.json({
status: 'NotAvailable',
checkedAt: new Date().toISOString(),
message: 'Uitgeschakeld door master',
isMasterControlled: true,
}),
),
);
mockAuthenticated();
renderApp('/settings');
expect(await screen.findByTestId('availability-master-controlled-banner', {}, { timeout: 5000 })).toBeInTheDocument();
expect(screen.getByTestId('mode-option-Available')).toBeDisabled();
expect(screen.getByTestId('mode-option-Maintenance')).toBeDisabled();
expect(screen.getByTestId('mode-option-NotAvailable')).toBeDisabled();
expect(screen.getByTestId('availability-reason-input')).toBeDisabled();
expect(screen.getByTestId('availability-save-button')).toBeDisabled();
});
it('does not show the master-controlled banner when not master-controlled', async () => {
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
expect(screen.queryByTestId('availability-master-controlled-banner')).not.toBeInTheDocument();
expect(screen.getByTestId('availability-save-button')).not.toBeDisabled();
});
it('shows a master-controlled error toast on a 409 conflict from the server', async () => {
server.use(
http.post(`${API_BASE}/api/v1/Availability/admin/status`, () =>
HttpResponse.json({ title: 'Conflict' }, { status: 409 }),
),
);
mockAuthenticated();
renderApp('/settings');
await screen.findByTestId('availability-save-button', {}, { timeout: 5000 });
await userEvent.click(screen.getByTestId('availability-save-button'));
expect(await screen.findByText(/master cms controls this status/i, {}, { timeout: 5000 })).toBeInTheDocument();
});
it('renders all placeholder sections', async () => {
mockAuthenticated();
renderApp('/settings');
+27 -5
View File
@@ -7,6 +7,7 @@ import { AvailabilityStatusBadge } from '@/components/shared/AvailabilityStatusB
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { ProblemDetailsError } from '@/lib/api-client';
import type { AvailabilityStatus } from '@/api/types';
function PlaceholderCard({ titleKey, comingSoonKey }: { titleKey: string; comingSoonKey: string }) {
@@ -41,12 +42,18 @@ export function SettingsPage() {
}
}, [availability]);
const isMasterControlled = availability?.isMasterControlled ?? false;
const handleSave = async () => {
try {
await updateAvailability.mutateAsync({ newStatus: selectedMode, reason });
toast.success(t('settings.availability.saveSuccess'));
} catch {
toast.error(t('errors.generic'));
} catch (err) {
if (err instanceof ProblemDetailsError && err.status === 409) {
toast.error(t('settings.availability.masterControlledSaveError'));
} else {
toast.error(t('errors.generic'));
}
}
};
@@ -73,6 +80,19 @@ export function SettingsPage() {
/>
) : null}
{isMasterControlled && (
<div
data-testid="availability-master-controlled-banner"
className="flex items-start gap-2 rounded-md border border-amber-400/50 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-200"
>
<Lock className="size-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">{t('settings.availability.masterControlledTitle')}</p>
<p>{t('settings.availability.masterControlled')}</p>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('settings.availability.mode')}</Label>
<div className="flex gap-2 flex-wrap" data-testid="availability-mode-selector">
@@ -81,8 +101,9 @@ export function SettingsPage() {
key={mode}
type="button"
onClick={() => setSelectedMode(mode)}
disabled={isMasterControlled}
data-testid={`mode-option-${mode}`}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
selectedMode === mode
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background hover:bg-accent'
@@ -103,15 +124,16 @@ export function SettingsPage() {
rows={2}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={isMasterControlled}
data-testid="availability-reason-input"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('settings.availability.reason')}
/>
</div>
<Button
onClick={handleSave}
disabled={updateAvailability.isPending}
disabled={updateAvailability.isPending || isMasterControlled}
data-testid="availability-save-button"
>
{updateAvailability.isPending
+4 -1
View File
@@ -13,6 +13,7 @@ import { AppLayout } from '@/components/layout/AppLayout';
import { LoginPage } from '@/pages/LoginPage';
import { NotFoundPage } from '@/pages/NotFoundPage';
import { RoleGuard } from '@/components/auth/RoleGuard';
import { ModuleGuard } from '@/components/auth/ModuleGuard';
export interface RouterContext {
auth: AuthContextValue;
@@ -189,7 +190,9 @@ const cmsRoute = createRoute({
path: '/cms',
component: () => (
<RoleGuard allowedRoles={['Owner']}>
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
<ModuleGuard requiredModule="Master">
{lazyPage(() => import('@/pages/CmsPage'), 'CmsPage')()}
</ModuleGuard>
</RoleGuard>
),
});
+2
View File
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
readonly VITE_ENABLE_MSW?: string;
/** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */
readonly VITE_APP_TITLE?: string;
// Add future typed env flags here.
}