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:
@@ -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