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:
@@ -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`.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+67
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const systemHandlers = [
|
||||
http.get('*/System/capabilities', () =>
|
||||
HttpResponse.json({
|
||||
modules: ['Availability', 'Identity', 'Master'],
|
||||
}),
|
||||
),
|
||||
];
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
Vendored
+2
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user