#245: mode hidden — hide profile-violating options, mark the hiding #294

Merged
fable-5 merged 1 commits from issue-245-vs-nfd-hidden into main 2026-07-31 19:43:28 +02:00
8 changed files with 220 additions and 101 deletions

View File

@ -196,6 +196,12 @@ jobs:
# cover the marked state (issue #244); mode off is covered by # cover the marked state (issue #244); mode off is covered by
# local full runs and the marking pack's off-assertions there. # 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 &) (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 &) (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 &) (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 for i in $(seq 1 30); do
@ -620,6 +626,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \ E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
pnpm --filter @dorfteich/web exec playwright test e2e/vs-nfd-marking.spec.ts 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 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). # the theme pack over the 10/min login limit — reset again (#244).
- name: Reset login rate limit before theme pack - name: Reset login rate limit before theme pack

View File

@ -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 `auth.spec.ts` skips itself when `E2E_MAILPIT_URL` is unset, so the CD
smoke run never trips over it. smoke run never trips over it.
`vs-nfd-marking.spec.ts` (issue #244) has two halves selected by `vs-nfd-marking.spec.ts` (issues #244/#245) has three parts selected by
`E2E_VS_NFD_MODE`: set it to `marked` **and** start the api with `E2E_VS_NFD_MODE` (`marked`, `hidden`, unset = `off`); each needs an api
`VS_NFD_MODE=marked` for the marking assertions (CI does this in the started with the matching `VS_NFD_MODE`. CI runs the marked half against
auth-e2e job); leave both unset for the no-marking-in-`off` assertions. 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 ## Fixture matrix

View File

@ -1,3 +1,4 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers'; import { contextForUser } from './helpers';
@ -46,6 +47,45 @@ test('mode marked: card, checkbox marking, and point-of-choice marking', async (
await context.close(); 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<string, boolean>;
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('mode off: no card, no markings', async ({ browser }) => {
test.skip(MODE !== 'off', 'covers the default stack only'); test.skip(MODE !== 'off', 'covers the default stack only');
const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); const context = await contextForUser(browser, BASE_URL, 'fixture-admin');

View File

@ -22,6 +22,9 @@ export function useVsNfdMarking(): {
view: VsNfdProfileView | undefined; view: VsNfdProfileView | undefined;
/** Translated marking text for a violating value, else undefined. */ /** Translated marking text for a violating value, else undefined. */
markingFor: (key: string, value: unknown) => string | 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 { t } = useTranslation('settings');
const query = useQuery({ const query = useQuery({
@ -34,16 +37,41 @@ export function useVsNfdMarking(): {
mode, mode,
view: query.data, view: query.data,
markingFor(key, value) { markingFor(key, value) {
// Marking is the `marked` treatment; `hidden`/`enforced` get their // Marking applies in `marked`, and in `hidden`/`enforced` to values
// own behaviour with #245/#246. // that are ALREADY violating — an existing violation is never
if (mode !== 'marked') return undefined; // silently hidden (#245).
if (mode === 'off') return undefined;
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key); const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
if (!entry || isVsNfdCompliant(entry, value)) return undefined; if (!entry || isVsNfdCompliant(entry, value)) return undefined;
return t('admin.vsNfd.marking', { value: describeCompliance(entry.compliance) }); 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 (
<p className="field__hint vs-nfd-hidden-note">
<span aria-hidden="true">🛈 </span>
{t('admin.vsNfd.hiddenNote')}
</p>
);
}
/** /**
* The marking element for controls not wrapped in <Field> (checkbox rows): * The marking element for controls not wrapped in <Field> (checkbox rows):
* icon + text, colour never alone; wire `id` into the control's * icon + text, colour never alone; wire `id` into the control's

View File

@ -10,7 +10,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { formatBytes } from '../files/file-format';
import { ApiError, apiGet, apiPost, apiPut } from '../lib/api'; import { ApiError, apiGet, apiPost, apiPut } from '../lib/api';
@ -216,6 +216,7 @@ function BackupSettingsForm(): React.JSX.Element {
if (!view) return <></>; if (!view) return <></>;
const form = draft ?? toDraft(view); const form = draft ?? toDraft(view);
const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled); const nextcloudMarking = vsNfd.markingFor('backup.nextcloud.enabled', form.enabled);
const nextcloudHidden = vsNfd.hides('backup.nextcloud.enabled', view.nextcloud.enabled);
const update = (patch: Partial<SettingsDraft>): void => setDraft({ ...form, ...patch }); const update = (patch: Partial<SettingsDraft>): void => setDraft({ ...form, ...patch });
const describeError = (error: unknown): string => { const describeError = (error: unknown): string => {
@ -307,6 +308,8 @@ function BackupSettingsForm(): React.JSX.Element {
})} })}
</p> </p>
)} )}
{nextcloudHidden && <VsNfdHiddenNote />}
{!nextcloudHidden && (
<label className="system-backup__checkbox"> <label className="system-backup__checkbox">
<input <input
type="checkbox" type="checkbox"
@ -317,7 +320,9 @@ function BackupSettingsForm(): React.JSX.Element {
/> />
{t('backup.settings.enabled')} {t('backup.settings.enabled')}
</label> </label>
)}
{nextcloudMarking && <VsNfdMark id="vs-nfd-mark-backup-nextcloud" text={nextcloudMarking} />} {nextcloudMarking && <VsNfdMark id="vs-nfd-mark-backup-nextcloud" text={nextcloudMarking} />}
{!nextcloudHidden && (
<fieldset <fieldset
disabled={!form.enabled || !view.remoteTargets.allowed} disabled={!form.enabled || !view.remoteTargets.allowed}
className="system-backup__nextcloud" className="system-backup__nextcloud"
@ -393,6 +398,7 @@ function BackupSettingsForm(): React.JSX.Element {
{busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')} {busy === 'test' ? t('backup.settings.testPending') : t('backup.settings.test')}
</button> </button>
</fieldset> </fieldset>
)}
<button type="submit" className="button system-backup__save" disabled={busy !== null}> <button type="submit" className="button system-backup__save" disabled={busy !== null}>
{busy === 'save' ? t('backup.settings.savePending') : t('backup.settings.save')} {busy === 'save' ? t('backup.settings.savePending') : t('backup.settings.save')}
</button> </button>

View File

@ -7,7 +7,7 @@ import { Link } from 'react-router-dom';
import { Field, FormError, FormSuccess } from '../components/forms'; import { Field, FormError, FormSuccess } from '../components/forms';
import { SettingsLayout } from '../components/SettingsLayout'; 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 { apiGet, apiPatch } from '../lib/api';
import { PluginManager } from './PluginManager'; import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager'; import { QuotaManager } from './QuotaManager';
@ -76,6 +76,15 @@ export function AdminSettingsPage(): React.JSX.Element {
<VsNfdProfileSection /> <VsNfdProfileSection />
<section className="settings-section"> <section className="settings-section">
<h2>{t('settings:admin.general')}</h2> <h2>{t('settings:admin.general')}</h2>
{(vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) ||
vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) ||
vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
)) && <VsNfdHiddenNote />}
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
<FormError error={error} /> <FormError error={error} />
<FormSuccess message={saved ? t('settings:admin.saved') : null} /> <FormSuccess message={saved ? t('settings:admin.saved') : null} />
@ -96,7 +105,9 @@ export function AdminSettingsPage(): React.JSX.Element {
)} )}
> >
<select {...form.register('auth.registrationMode')}> <select {...form.register('auth.registrationMode')}>
{!vsNfd.hides('auth.registrationMode', settings.data['auth.registrationMode']) && (
<option value="open">{t('settings:admin.registrationOpen')}</option> <option value="open">{t('settings:admin.registrationOpen')}</option>
)}
<option value="closed">{t('settings:admin.registrationClosed')}</option> <option value="closed">{t('settings:admin.registrationClosed')}</option>
</select> </select>
</Field> </Field>
@ -109,9 +120,14 @@ export function AdminSettingsPage(): React.JSX.Element {
)} )}
> >
<select {...form.register('classification.newPageDefault')}> <select {...form.register('classification.newPageDefault')}>
{!vsNfd.hides(
'classification.newPageDefault',
settings.data['classification.newPageDefault'],
) && (
<option value="unclassified"> <option value="unclassified">
{t('settings:admin.classificationUnclassified')} {t('settings:admin.classificationUnclassified')}
</option> </option>
)}
<option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option> <option value="vs_nfd">{t('settings:admin.classificationVsNfd')}</option>
</select> </select>
</Field> </Field>
@ -124,7 +140,10 @@ export function AdminSettingsPage(): React.JSX.Element {
)} )}
> >
<select {...form.register('classification.uploadPolicy')}> <select {...form.register('classification.uploadPolicy')}>
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option> {!vsNfd.hides(
'classification.uploadPolicy',
settings.data['classification.uploadPolicy'],
) && <option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>}
<option value="block">{t('settings:admin.uploadPolicyBlock')}</option> <option value="block">{t('settings:admin.uploadPolicyBlock')}</option>
</select> </select>
</Field> </Field>
@ -266,6 +285,7 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
onChange={(event) => setExtensions(event.target.value)} onChange={(event) => setExtensions(event.target.value)}
/> />
</Field> </Field>
{vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && <VsNfdHiddenNote />}
<Field <Field
label={t('settings.svgPolicy')} label={t('settings.svgPolicy')}
marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)} marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)}
@ -274,7 +294,9 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
value={svgPolicy} value={svgPolicy}
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')} onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
> >
{!vsNfd.hides('upload.svgPolicy', settings['upload.svgPolicy']) && (
<option value="sanitize">{t('settings.svgSanitize')}</option> <option value="sanitize">{t('settings.svgSanitize')}</option>
)}
<option value="reject">{t('settings.svgReject')}</option> <option value="reject">{t('settings.svgReject')}</option>
</select> </select>
</Field> </Field>
@ -314,6 +336,9 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re
<h2>{t('admin.title')}</h2> <h2>{t('admin.title')}</h2>
<FormError error={error} /> <FormError error={error} />
<FormSuccess message={saved ? t('admin.saved') : null} /> <FormSuccess message={saved ? t('admin.saved') : null} />
{(['api.enabled', 'mcp.enabled', 'feeds.enabled', 'plugins.enabled'] as const).some((key) =>
vsNfd.hides(key, settings[key]),
) && <VsNfdHiddenNote />}
{( {(
[ [
{ key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') }, { 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') }, { key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') },
] as const ] as const
).map((row) => { ).map((row) => {
if (vsNfd.hides(row.key, settings[row.key])) return null;
const marking = vsNfd.markingFor(row.key, settings[row.key]); const marking = vsNfd.markingFor(row.key, settings[row.key]);
return ( return (
<div key={row.key}> <div key={row.key}>

View File

@ -72,7 +72,8 @@
"referenceValue": "Referenzwert: {{value}}", "referenceValue": "Referenzwert: {{value}}",
"guideRef": "Härtungsleitfaden §{{section}}", "guideRef": "Härtungsleitfaden §{{section}}",
"marking": "Weicht von der VS-NfD-Referenzkonfiguration ab (Referenzwert: {{value}})", "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": { "landing": {

View File

@ -72,7 +72,8 @@
"referenceValue": "Reference value: {{value}}", "referenceValue": "Reference value: {{value}}",
"guideRef": "Hardening guide §{{section}}", "guideRef": "Hardening guide §{{section}}",
"marking": "Deviates from the VS-NfD reference configuration (reference value: {{value}})", "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": { "landing": {