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
+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.
}