diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b64ab40..fc5e25f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -196,6 +196,12 @@ jobs: # cover the marked state (issue #244); mode off is covered by # local full runs and the marking pack's off-assertions there. (cd apps/api && PORT=3001 VS_NFD_MODE=marked node dist/main.js > /tmp/api.log 2>&1 &) + # Second api on the SAME database with VS_NFD_MODE=hidden: the + # marking pack's hidden half runs against it via its own static + # server (issue #245); the mode is env-only, so sharing the db is + # exactly the deploy semantics. + (cd apps/api && PORT=3006 VS_NFD_MODE=hidden MIGRATE_ON_START=false node dist/main.js > /tmp/api-hidden.log 2>&1 &) + (PORT=5176 API_TARGET=http://127.0.0.1:3006 node scripts/e2e-static-server.mjs > /tmp/web-hidden.log 2>&1 &) (cd apps/collab && PORT=3002 node dist/index.js > /tmp/collab.log 2>&1 &) (PORT=5173 COLLAB_TARGET=http://127.0.0.1:3002 node scripts/e2e-static-server.mjs > /tmp/web.log 2>&1 &) for i in $(seq 1 30); do @@ -620,6 +626,16 @@ jobs: E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \ pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts + # Ausblendung + Policy-Hinweis im Modus `hidden` (issue #245). + - name: Run VS-NfD hidden pack + run: | + for i in $(seq 1 30); do + curl -sf http://localhost:3006/api/v1/readyz >/dev/null && break + sleep 2 + done + E2E_BASE_URL=http://localhost:5176 E2E_VS_NFD_MODE=hidden \ + pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts + # The marking pack's extra login on top of the six a11y logins pushes # the theme pack over the 10/min login limit — reset again (#244). - name: Reset login rate limit before theme pack diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index b96b002..4540de9 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -36,10 +36,11 @@ E2E_BASE_URL=http://localhost:5173 E2E_MAILPIT_URL=http://localhost:8025 pnpm -- `auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD smoke run never trips over it. -`vs-nfd-marking.spec.ts` (issue #244) has two halves selected by -`E2E_VS_NFD_MODE`: set it to `marked` **and** start the api with -`VS_NFD_MODE=marked` for the marking assertions (CI does this in the -auth-e2e job); leave both unset for the no-marking-in-`off` assertions. +`vs-nfd-marking.spec.ts` (issues #244/#245) has three parts selected by +`E2E_VS_NFD_MODE` (`marked`, `hidden`, unset = `off`); each needs an api +started with the matching `VS_NFD_MODE`. CI runs the marked half against +the main e2e stack and the hidden half against a second api (port 3006) +on the same database; the off assertions run in local default stacks. ## Fixture matrix diff --git a/apps/web/e2e/vs-nfd-marking.spec.ts b/apps/web/e2e/vs-nfd-marking.spec.ts index cad53c2..820947c 100644 --- a/apps/web/e2e/vs-nfd-marking.spec.ts +++ b/apps/web/e2e/vs-nfd-marking.spec.ts @@ -1,3 +1,4 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; import { contextForUser } from './helpers'; @@ -46,6 +47,45 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async ( await context.close(); }); +test('mode hidden: rows disappear, notes mark the hiding, a11y clean', async ({ browser }) => { + test.skip(MODE !== 'hidden', 'needs an api started with VS_NFD_MODE=hidden'); + const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); + const page = await context.newPage(); + await page.goto('/admin'); + await page.waitForLoadState('networkidle'); + + // feeds.enabled defaults to true = ALREADY violating: never silently + // hidden — the row stays visible with its marking. + await expect(page.locator('#vs-nfd-mark-feeds\\.enabled')).toBeVisible(); + + // Master switches: compliant (false) rows disappear, violating (true) + // rows stay — derive the expectation from the actual instance settings, + // the seed state differs between local and CI databases. + const settings = (await ( + await context.request.get(`${BASE_URL}/api/v1/admin/settings`) + ).json()) as Record; + const masterKeys = ['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const; + const visible = masterKeys.filter((key) => settings[key] === true).length; + expect(visible).toBeLessThan(masterKeys.length); // at least one row is hidden + await expect(page.locator('.api-opt-in__label')).toHaveCount(visible); + await expect(page.locator('.vs-nfd-hidden-note').first()).toBeVisible(); + + // Value-listed control: the compliant registration mode keeps only its + // compliant choice (seed leaves it open = violating? then all options). + const regSelect = page.locator('select[name="auth.registrationMode"]'); + const regField = page.locator('label.field', { has: regSelect }); + const optionCount = await regSelect.locator('option').count(); + const marked = await regField.locator('.vs-nfd-mark').count(); + // Exactly one of the two treatments applies, never a silent third state. + expect(optionCount === 1 || marked === 1).toBe(true); + + // The hidden state and the policy note pass the axe WCAG A/AA scan. + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + expect(results.violations).toEqual([]); + + await context.close(); +}); + test('mode off: no card, no markings', async ({ browser }) => { test.skip(MODE !== 'off', 'covers the default stack only'); const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); diff --git a/apps/web/src/components/vs-nfd.tsx b/apps/web/src/components/vs-nfd.tsx index b5596e9..4e25134 100644 --- a/apps/web/src/components/vs-nfd.tsx +++ b/apps/web/src/components/vs-nfd.tsx @@ -22,6 +22,9 @@ export function useVsNfdMarking(): { view: VsNfdProfileView | undefined; /** Translated marking text for a violating value, else undefined. */ markingFor: (key: string, value: unknown) => string | undefined; + /** True when the control must not be rendered (#245): mode hidden/ + * enforced and the SAVED value is compliant. */ + hides: (key: string, savedValue: unknown) => boolean; } { const { t } = useTranslation('settings'); const query = useQuery({ @@ -34,16 +37,41 @@ export function useVsNfdMarking(): { mode, view: query.data, markingFor(key, value) { - // Marking is the `marked` treatment; `hidden`/`enforced` get their - // own behaviour with #245/#246. - if (mode !== 'marked') return undefined; + // Marking applies in `marked`, and in `hidden`/`enforced` to values + // that are ALREADY violating — an existing violation is never + // silently hidden (#245). + if (mode === 'off') return undefined; const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key); if (!entry || isVsNfdCompliant(entry, value)) return undefined; return t('admin.vsNfd.marking', { value: describeCompliance(entry.compliance) }); }, + hides(key, savedValue) { + // The `hidden` treatment (#245, `enforced` renders the same, #246): + // a compliant control whose only purpose would be enabling a + // violation disappears; a violating one stays visible WITH its + // marking so the deviation is surfaced, never buried. + if (mode !== 'hidden' && mode !== 'enforced') return false; + const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key); + return entry !== undefined && isVsNfdCompliant(entry, savedValue); + }, }; } +/** + * The one accessible per-section note that options are hidden by + * deployment policy (#245) — policy must be distinguishable from missing + * features for anyone reading the settings page. + */ +export function VsNfdHiddenNote(): React.JSX.Element { + const { t } = useTranslation('settings'); + return ( +

+ + {t('admin.vsNfd.hiddenNote')} +

+ ); +} + /** * The marking element for controls not wrapped in (checkbox rows): * icon + text, colour never alone; wire `id` into the control's diff --git a/apps/web/src/pages/AdminBackupSection.tsx b/apps/web/src/pages/AdminBackupSection.tsx index d5b0072..937835d 100644 --- a/apps/web/src/pages/AdminBackupSection.tsx +++ b/apps/web/src/pages/AdminBackupSection.tsx @@ -10,7 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; +import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { formatBytes } from '../files/file-format'; import { ApiError, apiGet, apiPost, apiPut } from '../lib/api'; @@ -216,6 +216,7 @@ function BackupSettingsForm(): React.JSX.Element { if (!view) return <>; const form = draft ?? toDraft(view); const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled); + const nextcloudHidden = vsNfd.hides('backup.nextcloud.enabled', view.nextcloud.enabled); const update = (patch: Partial): void => setDraft({ ...form, ...patch }); const describeError = (error: unknown): string => { @@ -307,92 +308,97 @@ function BackupSettingsForm(): React.JSX.Element { })}

)} - + {nextcloudHidden && } + {!nextcloudHidden && ( + + )} {nextcloudMarking && } -
- -

{t('backup.settings.baseUrlHint')}

- - -

{t('backup.settings.passwordHint')}

- - - - -
+ +

{t('backup.settings.baseUrlHint')}

+ + +

{t('backup.settings.passwordHint')}

+ + + + + + )} diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index ab88c94..397064a 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -7,7 +7,7 @@ import { Link } from 'react-router-dom'; import { Field, FormError, FormSuccess } from '../components/forms'; import { SettingsLayout } from '../components/SettingsLayout'; -import { VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; +import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd'; import { apiGet, apiPatch } from '../lib/api'; import { PluginManager } from './PluginManager'; import { QuotaManager } from './QuotaManager'; @@ -76,6 +76,15 @@ export function AdminSettingsPage(): React.JSX.Element {

{t('settings:admin.general')}

+ {(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) || + vsNfd.hides( + 'classification.newPageDefault', + settings.data['classification.newPageDefault'], + ) || + vsNfd.hides( + 'classification.uploadPolicy', + settings.data['classification.uploadPolicy'], + )) && }
@@ -96,7 +105,9 @@ export function AdminSettingsPage(): React.JSX.Element { )} > @@ -109,9 +120,14 @@ export function AdminSettingsPage(): React.JSX.Element { )} > @@ -124,7 +140,10 @@ export function AdminSettingsPage(): React.JSX.Element { )} > @@ -266,6 +285,7 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React onChange={(event) => setExtensions(event.target.value)} /> + {vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && } setSvgPolicy(event.target.value as 'reject' | 'sanitize')} > - + {!vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && ( + + )} @@ -314,6 +336,9 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re

{t('admin.title')}

+ {(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) => + vsNfd.hides(key, settings[key]), + ) && } {( [ { key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') }, @@ -322,6 +347,7 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re { key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') }, ] as const ).map((row) => { + if (vsNfd.hides(row.key, settings[row.key])) return null; const marking = vsNfd.markingFor(row.key, settings[row.key]); return (
diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index c273391..5bb0741 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -72,7 +72,8 @@ "referenceValue": "Referenzwert: {{value}}", "guideRef": "Härtungsleitfaden §{{section}}", "marking": "Weicht von der VS-NfD-Referenzkonfiguration ab (Referenzwert: {{value}})", - "guideNote": "Referenzkonfiguration und Begründungen:" + "guideNote": "Referenzkonfiguration und Begründungen:", + "hiddenNote": "Einzelne Optionen sind durch die Deployment-Policy (VS-NfD-Modus) ausgeblendet — das ist Absicht, kein fehlendes Feature." } }, "landing": { diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index a7d0dcb..a332a62 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -72,7 +72,8 @@ "referenceValue": "Reference value: {{value}}", "guideRef": "Hardening guide §{{section}}", "marking": "Deviates from the VS-NfD reference configuration (reference value: {{value}})", - "guideNote": "Reference configuration and rationale:" + "guideNote": "Reference configuration and rationale:", + "hiddenNote": "Some options are hidden by deployment policy (VS-NfD mode) — that is intentional, not a missing feature." } }, "landing": {