Compare commits
1 Commits
1c774596e1
...
5fdef95f67
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fdef95f67 |
@ -192,7 +192,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Start api, collab, and static web server
|
- name: Start api, collab, and static web server
|
||||||
run: |
|
run: |
|
||||||
(cd apps/api && PORT=3001 node dist/main.js > /tmp/api.log 2>&1 &)
|
# VS_NFD_MODE=marked: the marking pack and the a11y admin scan
|
||||||
|
# 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 &)
|
||||||
(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
|
||||||
@ -611,6 +614,19 @@ jobs:
|
|||||||
E2E_BASE_URL=http://localhost:5173 \
|
E2E_BASE_URL=http://localhost:5173 \
|
||||||
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
||||||
|
|
||||||
|
# VS-NfD-Markierungen im Modus `marked` (issue #244).
|
||||||
|
- name: Run VS-NfD marking pack
|
||||||
|
run: |
|
||||||
|
E2E_BASE_URL=http://localhost:5173 E2E_VS_NFD_MODE=marked \
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||||
|
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||||
|
|
||||||
# Hell/Dunkel/System-Umschalter (issue #180).
|
# Hell/Dunkel/System-Umschalter (issue #180).
|
||||||
- name: Run theme pack
|
- name: Run theme pack
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@ -36,6 +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
|
||||||
|
`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.
|
||||||
|
|
||||||
## Fixture matrix
|
## Fixture matrix
|
||||||
|
|
||||||
Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running
|
Seeded by `pnpm --filter @dorfteich/api db:seed` (idempotent — re-running
|
||||||
|
|||||||
60
apps/web/e2e/vs-nfd-marking.spec.ts
Normal file
60
apps/web/e2e/vs-nfd-marking.spec.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { contextForUser } from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS-NfD marking pack (issue #244): in mode `marked`, catalog-listed
|
||||||
|
* controls carry an accessible deviation marking that follows the CURRENT
|
||||||
|
* (unsaved) value; in mode `off` nothing is marked anywhere. The api's
|
||||||
|
* mode is deploy-level, so each half runs only against the matching stack:
|
||||||
|
* CI runs the marked half (VS_NFD_MODE=marked on the e2e api), local full
|
||||||
|
* runs against a default stack cover the off half.
|
||||||
|
*/
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||||
|
const MODE = process.env.E2E_VS_NFD_MODE ?? 'off';
|
||||||
|
|
||||||
|
test('mode marked: card, checkbox marking, and point-of-choice marking', async ({ browser }) => {
|
||||||
|
test.skip(MODE !== 'marked', 'needs an api started with VS_NFD_MODE=marked');
|
||||||
|
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// The profile card shows mode and violation count (issue #243).
|
||||||
|
await expect(page.locator('.vs-nfd-summary')).toBeVisible();
|
||||||
|
|
||||||
|
// feeds.enabled defaults to true = violating: its toggle row is marked,
|
||||||
|
// and the marking is part of the control's accessible description.
|
||||||
|
const feedsInput = page.locator('input[aria-describedby="vs-nfd-mark-feeds.enabled"]');
|
||||||
|
await expect(feedsInput).toBeVisible();
|
||||||
|
await expect(page.locator('#vs-nfd-mark-feeds\\.enabled')).toBeVisible();
|
||||||
|
|
||||||
|
// Point of choice: the registration-mode marking follows the UNSAVED
|
||||||
|
// select value — compliant choice clears it, violating choice brings it
|
||||||
|
// back, no save in between.
|
||||||
|
const regField = page.locator('label.field', {
|
||||||
|
has: page.locator('select[name="auth.registrationMode"]'),
|
||||||
|
});
|
||||||
|
const regSelect = regField.locator('select');
|
||||||
|
await regSelect.selectOption('open');
|
||||||
|
await expect(regField.locator('.vs-nfd-mark')).toBeVisible();
|
||||||
|
await regSelect.selectOption('closed');
|
||||||
|
await expect(regField.locator('.vs-nfd-mark')).toHaveCount(0);
|
||||||
|
await regSelect.selectOption('open');
|
||||||
|
await expect(regField.locator('.vs-nfd-mark')).toBeVisible();
|
||||||
|
|
||||||
|
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');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page.locator('.vs-nfd-summary')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.vs-nfd-mark')).toHaveCount(0);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
@ -13,22 +13,30 @@ export function Field({
|
|||||||
label,
|
label,
|
||||||
error,
|
error,
|
||||||
hint,
|
hint,
|
||||||
|
marking,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** VS-NfD deviation marking (issue #244): icon + text under the control,
|
||||||
|
* part of its accessible description like the hint. */
|
||||||
|
marking?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const noteId = useId();
|
const noteId = useId();
|
||||||
// Tie the hint/error text to the control itself (#168, WCAG 3.3.1):
|
const markId = useId();
|
||||||
// screen readers then repeat it when the field receives focus. Only a
|
// Tie the hint/error/marking text to the control itself (#168, WCAG
|
||||||
// single element child can be wired; fragments render unchanged.
|
// 3.3.1): screen readers then repeat it when the field receives focus.
|
||||||
|
// Only a single element child can be wired; fragments render unchanged.
|
||||||
|
const describedBy = [error || hint ? noteId : null, marking ? markId : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
const wired =
|
const wired =
|
||||||
isValidElement(children) && (error || hint)
|
isValidElement(children) && describedBy
|
||||||
? cloneElement(children as React.ReactElement<Record<string, unknown>>, {
|
? cloneElement(children as React.ReactElement<Record<string, unknown>>, {
|
||||||
'aria-describedby': noteId,
|
'aria-describedby': describedBy,
|
||||||
...(error ? { 'aria-invalid': true } : {}),
|
...(error ? { 'aria-invalid': true } : {}),
|
||||||
})
|
})
|
||||||
: children;
|
: children;
|
||||||
@ -46,6 +54,12 @@ export function Field({
|
|||||||
{t(`errors:${error}`, t('errors:bad_request'))}
|
{t(`errors:${error}`, t('errors:bad_request'))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{marking && (
|
||||||
|
<span className="vs-nfd-mark" id={markId}>
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{marking}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
apps/web/src/components/vs-nfd.tsx
Normal file
59
apps/web/src/components/vs-nfd.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
VS_NFD_PROFILE,
|
||||||
|
describeCompliance,
|
||||||
|
isVsNfdCompliant,
|
||||||
|
type VsNfdMode,
|
||||||
|
type VsNfdProfileView,
|
||||||
|
} from '@dorfteich/shared';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS-NfD profile marking (issue #244, ADR 0027): in mode `marked`, every
|
||||||
|
* catalog-listed control shows an accessible deviation marking. The check
|
||||||
|
* runs against the CURRENT control value (saved or not), so the operator
|
||||||
|
* is warned at the point of choice. Admin-only surfaces — the endpoint is
|
||||||
|
* Site-Admin-guarded, matching every caller.
|
||||||
|
*/
|
||||||
|
export function useVsNfdMarking(): {
|
||||||
|
mode: VsNfdMode;
|
||||||
|
view: VsNfdProfileView | undefined;
|
||||||
|
/** Translated marking text for a violating value, else undefined. */
|
||||||
|
markingFor: (key: string, value: unknown) => string | undefined;
|
||||||
|
} {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['admin', 'vs-nfd-profile'],
|
||||||
|
queryFn: () => apiGet<VsNfdProfileView>('/admin/system/vs-nfd-profile'),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const mode = query.data?.mode ?? 'off';
|
||||||
|
return {
|
||||||
|
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;
|
||||||
|
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) });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The marking element for controls not wrapped in <Field> (checkbox rows):
|
||||||
|
* icon + text, colour never alone; wire `id` into the control's
|
||||||
|
* aria-describedby so screen readers announce it with the control.
|
||||||
|
*/
|
||||||
|
export function VsNfdMark({ id, text }: { id?: string; text: string }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<span className="vs-nfd-mark" id={id}>
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +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 { 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';
|
||||||
|
|
||||||
@ -202,6 +203,7 @@ function BackupSettingsForm(): React.JSX.Element {
|
|||||||
const { t } = useTranslation('system');
|
const { t } = useTranslation('system');
|
||||||
const { t: tErrors } = useTranslation('errors');
|
const { t: tErrors } = useTranslation('errors');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const vsNfd = useVsNfdMarking();
|
||||||
const [draft, setDraft] = useState<SettingsDraft | null>(null);
|
const [draft, setDraft] = useState<SettingsDraft | null>(null);
|
||||||
const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||||
const [busy, setBusy] = useState<'test' | 'save' | null>(null);
|
const [busy, setBusy] = useState<'test' | 'save' | null>(null);
|
||||||
@ -213,6 +215,7 @@ function BackupSettingsForm(): React.JSX.Element {
|
|||||||
const view = query.data;
|
const view = query.data;
|
||||||
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 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 => {
|
||||||
@ -309,10 +312,12 @@ function BackupSettingsForm(): React.JSX.Element {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={form.enabled}
|
checked={form.enabled}
|
||||||
disabled={!view.remoteTargets.allowed}
|
disabled={!view.remoteTargets.allowed}
|
||||||
|
aria-describedby={nextcloudMarking ? 'vs-nfd-mark-backup-nextcloud' : undefined}
|
||||||
onChange={(e) => update({ enabled: e.target.checked })}
|
onChange={(e) => update({ enabled: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
{t('backup.settings.enabled')}
|
{t('backup.settings.enabled')}
|
||||||
</label>
|
</label>
|
||||||
|
{nextcloudMarking && <VsNfdMark id="vs-nfd-mark-backup-nextcloud" text={nextcloudMarking} />}
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={!form.enabled || !view.remoteTargets.allowed}
|
disabled={!form.enabled || !view.remoteTargets.allowed}
|
||||||
className="system-backup__nextcloud"
|
className="system-backup__nextcloud"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { docToHtml, markdownToDoc, type VsNfdProfileView } from '@dorfteich/shared';
|
import { docToHtml, markdownToDoc } from '@dorfteich/shared';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -7,6 +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 { 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';
|
||||||
@ -49,6 +50,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<InstanceSettings>({ values: settings.data });
|
const form = useForm<InstanceSettings>({ values: settings.data });
|
||||||
|
const vsNfd = useVsNfdMarking();
|
||||||
|
|
||||||
const onSubmit = form.handleSubmit(async (input) => {
|
const onSubmit = form.handleSubmit(async (input) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -86,7 +88,13 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<option value="en">{t('settings:profile.locales.en')}</option>
|
<option value="en">{t('settings:profile.locales.en')}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('settings:admin.registrationMode')}>
|
<Field
|
||||||
|
label={t('settings:admin.registrationMode')}
|
||||||
|
marking={vsNfd.markingFor(
|
||||||
|
'auth.registrationMode',
|
||||||
|
form.watch('auth.registrationMode'),
|
||||||
|
)}
|
||||||
|
>
|
||||||
<select {...form.register('auth.registrationMode')}>
|
<select {...form.register('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>
|
||||||
@ -95,6 +103,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<Field
|
<Field
|
||||||
label={t('settings:admin.newPageClassification')}
|
label={t('settings:admin.newPageClassification')}
|
||||||
hint={t('settings:admin.newPageClassificationHelp')}
|
hint={t('settings:admin.newPageClassificationHelp')}
|
||||||
|
marking={vsNfd.markingFor(
|
||||||
|
'classification.newPageDefault',
|
||||||
|
form.watch('classification.newPageDefault'),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<select {...form.register('classification.newPageDefault')}>
|
<select {...form.register('classification.newPageDefault')}>
|
||||||
<option value="unclassified">
|
<option value="unclassified">
|
||||||
@ -106,6 +118,10 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<Field
|
<Field
|
||||||
label={t('settings:admin.uploadPolicy')}
|
label={t('settings:admin.uploadPolicy')}
|
||||||
hint={t('settings:admin.uploadPolicyHelp')}
|
hint={t('settings:admin.uploadPolicyHelp')}
|
||||||
|
marking={vsNfd.markingFor(
|
||||||
|
'classification.uploadPolicy',
|
||||||
|
form.watch('classification.uploadPolicy'),
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<select {...form.register('classification.uploadPolicy')}>
|
<select {...form.register('classification.uploadPolicy')}>
|
||||||
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
|
<option value="warn">{t('settings:admin.uploadPolicyWarn')}</option>
|
||||||
@ -162,26 +178,26 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
*/
|
*/
|
||||||
function VsNfdProfileSection(): React.JSX.Element | null {
|
function VsNfdProfileSection(): React.JSX.Element | null {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const profile = useQuery({
|
const { view } = useVsNfdMarking();
|
||||||
queryKey: ['admin', 'vs-nfd-profile'],
|
|
||||||
queryFn: () => apiGet<VsNfdProfileView>('/admin/system/vs-nfd-profile'),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!profile.data || profile.data.mode === 'off') return null;
|
if (!view || view.mode === 'off') return null;
|
||||||
const violations = profile.data.entries.filter((entry) => !entry.compliant);
|
const violations = view.entries.filter((entry) => !entry.compliant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h2>{t('admin.vsNfd.title')}</h2>
|
<h2>{t('admin.vsNfd.title')}</h2>
|
||||||
<p>{t('admin.vsNfd.intro')}</p>
|
<p>{t('admin.vsNfd.intro')}</p>
|
||||||
<p>
|
<p>
|
||||||
{t('admin.vsNfd.modeLabel')}: <strong>{t(`admin.vsNfd.modes.${profile.data.mode}`)}</strong>
|
{t('admin.vsNfd.modeLabel')}: <strong>{t(`admin.vsNfd.modes.${view.mode}`)}</strong>
|
||||||
</p>
|
</p>
|
||||||
{violations.length === 0 ? (
|
{violations.length === 0 ? (
|
||||||
<p>{t('admin.vsNfd.compliant')}</p>
|
<p>{t('admin.vsNfd.compliant')}</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>{t('admin.vsNfd.violations', { count: violations.length })}</p>
|
<p className="vs-nfd-summary">
|
||||||
|
<span aria-hidden="true">⚠ </span>
|
||||||
|
{t('admin.vsNfd.violations', { count: violations.length })}
|
||||||
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
{violations.map((entry) => (
|
{violations.map((entry) => (
|
||||||
<li key={`${entry.scope}:${entry.key}`}>
|
<li key={`${entry.scope}:${entry.key}`}>
|
||||||
@ -193,6 +209,9 @@ function VsNfdProfileSection(): React.JSX.Element | null {
|
|||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<p className="field__hint">
|
||||||
|
{t('admin.vsNfd.guideNote')} <code>docs/vs-nfd/50-haertungsleitfaden.md</code>
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -205,6 +224,7 @@ function VsNfdProfileSection(): React.JSX.Element | null {
|
|||||||
function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
||||||
const { t } = useTranslation('files');
|
const { t } = useTranslation('files');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const vsNfd = useVsNfdMarking();
|
||||||
const [extensions, setExtensions] = useState(settings['upload.allowedExtensions'].join(', '));
|
const [extensions, setExtensions] = useState(settings['upload.allowedExtensions'].join(', '));
|
||||||
const [svgPolicy, setSvgPolicy] = useState(settings['upload.svgPolicy']);
|
const [svgPolicy, setSvgPolicy] = useState(settings['upload.svgPolicy']);
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
@ -246,7 +266,10 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
|
|||||||
onChange={(event) => setExtensions(event.target.value)}
|
onChange={(event) => setExtensions(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t('settings.svgPolicy')}>
|
<Field
|
||||||
|
label={t('settings.svgPolicy')}
|
||||||
|
marking={vsNfd.markingFor('upload.svgPolicy', svgPolicy)}
|
||||||
|
>
|
||||||
<select
|
<select
|
||||||
value={svgPolicy}
|
value={svgPolicy}
|
||||||
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
|
onChange={(event) => setSvgPolicy(event.target.value as 'reject' | 'sanitize')}
|
||||||
@ -270,6 +293,7 @@ function UploadSettingsForm({ settings }: { settings: InstanceSettings }): React
|
|||||||
function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
||||||
const { t } = useTranslation('apiTokens');
|
const { t } = useTranslation('apiTokens');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const vsNfd = useVsNfdMarking();
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
@ -290,42 +314,31 @@ 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} />
|
||||||
<label className="api-opt-in__label">
|
{(
|
||||||
<input
|
[
|
||||||
type="checkbox"
|
{ key: 'api.enabled', label: t('admin.label'), hint: t('admin.hint') },
|
||||||
checked={settings['api.enabled']}
|
{ key: 'mcp.enabled', label: t('admin.mcpLabel'), hint: t('admin.mcpHint') },
|
||||||
onChange={(event) => void save({ 'api.enabled': event.target.checked })}
|
{ key: 'feeds.enabled', label: t('admin.feedsLabel'), hint: t('admin.feedsHint') },
|
||||||
/>
|
{ key: 'plugins.enabled', label: t('admin.pluginsLabel'), hint: t('admin.pluginsHint') },
|
||||||
{t('admin.label')}
|
] as const
|
||||||
</label>
|
).map((row) => {
|
||||||
<p className="api-opt-in__hint">{t('admin.hint')}</p>
|
const marking = vsNfd.markingFor(row.key, settings[row.key]);
|
||||||
<label className="api-opt-in__label">
|
return (
|
||||||
<input
|
<div key={row.key}>
|
||||||
type="checkbox"
|
<label className="api-opt-in__label">
|
||||||
checked={settings['mcp.enabled']}
|
<input
|
||||||
onChange={(event) => void save({ 'mcp.enabled': event.target.checked })}
|
type="checkbox"
|
||||||
/>
|
checked={settings[row.key]}
|
||||||
{t('admin.mcpLabel')}
|
aria-describedby={marking ? `vs-nfd-mark-${row.key}` : undefined}
|
||||||
</label>
|
onChange={(event) => void save({ [row.key]: event.target.checked })}
|
||||||
<p className="api-opt-in__hint">{t('admin.mcpHint')}</p>
|
/>
|
||||||
<label className="api-opt-in__label">
|
{row.label}
|
||||||
<input
|
</label>
|
||||||
type="checkbox"
|
{marking && <VsNfdMark id={`vs-nfd-mark-${row.key}`} text={marking} />}
|
||||||
checked={settings['feeds.enabled']}
|
<p className="api-opt-in__hint">{row.hint}</p>
|
||||||
onChange={(event) => void save({ 'feeds.enabled': event.target.checked })}
|
</div>
|
||||||
/>
|
);
|
||||||
{t('admin.feedsLabel')}
|
})}
|
||||||
</label>
|
|
||||||
<p className="api-opt-in__hint">{t('admin.feedsHint')}</p>
|
|
||||||
<label className="api-opt-in__label">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={settings['plugins.enabled']}
|
|
||||||
onChange={(event) => void save({ 'plugins.enabled': event.target.checked })}
|
|
||||||
/>
|
|
||||||
{t('admin.pluginsLabel')}
|
|
||||||
</label>
|
|
||||||
<p className="api-opt-in__hint">{t('admin.pluginsHint')}</p>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -390,6 +403,7 @@ function LandingSettingsForm({ settings }: { settings: InstanceSettings }): Reac
|
|||||||
function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.JSX.Element {
|
||||||
const { t } = useTranslation('legal');
|
const { t } = useTranslation('legal');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const vsNfd = useVsNfdMarking();
|
||||||
const [imprint, setImprint] = useState(settings['legal.imprint']);
|
const [imprint, setImprint] = useState(settings['legal.imprint']);
|
||||||
const [privacy, setPrivacy] = useState(settings['legal.privacyPolicy']);
|
const [privacy, setPrivacy] = useState(settings['legal.privacyPolicy']);
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
@ -423,8 +437,18 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
|
|||||||
<form onSubmit={(event) => void onSubmit(event)} noValidate>
|
<form onSubmit={(event) => void onSubmit(event)} noValidate>
|
||||||
<FormError error={error} />
|
<FormError error={error} />
|
||||||
<FormSuccess message={saved ? t('admin.save') : null} />
|
<FormSuccess message={saved ? t('admin.save') : null} />
|
||||||
<MarkdownTextField label={t('admin.imprint')} value={imprint} onChange={setImprint} />
|
<MarkdownTextField
|
||||||
<MarkdownTextField label={t('admin.privacyPolicy')} value={privacy} onChange={setPrivacy} />
|
label={t('admin.imprint')}
|
||||||
|
value={imprint}
|
||||||
|
onChange={setImprint}
|
||||||
|
marking={vsNfd.markingFor('legal.imprint', imprint)}
|
||||||
|
/>
|
||||||
|
<MarkdownTextField
|
||||||
|
label={t('admin.privacyPolicy')}
|
||||||
|
value={privacy}
|
||||||
|
onChange={setPrivacy}
|
||||||
|
marking={vsNfd.markingFor('legal.privacyPolicy', privacy)}
|
||||||
|
/>
|
||||||
<button type="submit" className="button" disabled={busy}>
|
<button type="submit" className="button" disabled={busy}>
|
||||||
{t('admin.save')}
|
{t('admin.save')}
|
||||||
</button>
|
</button>
|
||||||
@ -443,18 +467,20 @@ function MarkdownTextField({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
marking,
|
||||||
wrapperClass = 'legal-editor',
|
wrapperClass = 'legal-editor',
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
marking?: string;
|
||||||
wrapperClass?: string;
|
wrapperClass?: string;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('legal');
|
const { t } = useTranslation('legal');
|
||||||
const [preview, setPreview] = useState(false);
|
const [preview, setPreview] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className={wrapperClass}>
|
<div className={wrapperClass}>
|
||||||
<Field label={label}>
|
<Field label={label} marking={marking}>
|
||||||
<textarea
|
<textarea
|
||||||
rows={10}
|
rows={10}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@ -726,6 +726,20 @@ button {
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* VS-NfD deviation marking (issue #244): icon + text carry the meaning,
|
||||||
|
the colour only reinforces it (ADR 0017 — never colour alone). */
|
||||||
|
.vs-nfd-mark {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs-nfd-summary {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
|
|||||||
@ -70,7 +70,9 @@
|
|||||||
"violations_one": "{{count}} Einstellung weicht von der Referenzkonfiguration ab:",
|
"violations_one": "{{count}} Einstellung weicht von der Referenzkonfiguration ab:",
|
||||||
"violations_other": "{{count}} Einstellungen weichen von der Referenzkonfiguration ab:",
|
"violations_other": "{{count}} Einstellungen weichen von der Referenzkonfiguration ab:",
|
||||||
"referenceValue": "Referenzwert: {{value}}",
|
"referenceValue": "Referenzwert: {{value}}",
|
||||||
"guideRef": "Härtungsleitfaden §{{section}}"
|
"guideRef": "Härtungsleitfaden §{{section}}",
|
||||||
|
"marking": "Weicht von der VS-NfD-Referenzkonfiguration ab (Referenzwert: {{value}})",
|
||||||
|
"guideNote": "Referenzkonfiguration und Begründungen:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
|
|||||||
@ -70,7 +70,9 @@
|
|||||||
"violations_one": "{{count}} setting deviates from the reference configuration:",
|
"violations_one": "{{count}} setting deviates from the reference configuration:",
|
||||||
"violations_other": "{{count}} settings deviate from the reference configuration:",
|
"violations_other": "{{count}} settings deviate from the reference configuration:",
|
||||||
"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}})",
|
||||||
|
"guideNote": "Reference configuration and rationale:"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user