diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 0aabe27..4f8d105 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -311,6 +311,36 @@ async function seedContentFixtures(ownerId: string): Promise { deriveContentOf(everyElementDoc), ); + // "Classified Note" (issue #206, ADR 0022): a VS-NfD-marked page so e2e + // (a11y pack) can assert the marking banner in both themes. Kept simple — + // the marking, not the content, is what the fixture exists for. + const classifiedDoc = editorSchema.node('doc', null, [ + editorSchema.node('heading', { level: 1 }, [editorSchema.text('Classified Note')]), + editorSchema.node('paragraph', null, [ + editorSchema.text('This fixture page carries the VS-NfD marking.'), + ]), + ]); + const classifiedYdoc = new Y.Doc(); + prosemirrorJSONToYXmlFragment( + editorSchema, + classifiedDoc.toJSON(), + classifiedYdoc.getXmlFragment('default'), + ); + const classifiedState = new Uint8Array(Y.encodeStateAsUpdate(classifiedYdoc)); + classifiedYdoc.destroy(); + const classifiedPageId = await upsertFixturePage( + pond.id, + 'classified-note', + 'Classified Note', + ownerId, + classifiedState, + deriveContentOf(classifiedDoc), + ); + await prisma.page.update({ + where: { id: classifiedPageId }, + data: { classification: 'VS_NFD' }, + }); + // "Fixture Image": one real, servable uploaded image (the Markdown // fixture above only carries a placeholder fileId for round-trip // testing — this is the one that actually resolves via /media/:fileId). diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 1c596b3..e1aea15 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import type { PageCommentsView } from '@dorfteich/shared'; -import { Pond, User } from '@prisma/client'; +import type { PageClassification, PageCommentsView } from '@dorfteich/shared'; +import { Page, Pond, User } from '@prisma/client'; import { CommentsService } from '../comments/comments.service'; import { TasksService } from '../pages/tasks.service'; @@ -18,12 +18,21 @@ export interface PublicPageContent { slug: string; /** Pre-rendered body HTML from the content cache (issue #24). */ html: string; + /** VS-NfD marking level (ADR 0022, issue #206) — the read view renders it + * above and below the content. */ + classification: PageClassification; updatedAt: string; } interface ResolvedPage { pond: Pond; - page: { id: string; pondId: string; slug: string; title: string }; + page: { + id: string; + pondId: string; + slug: string; + title: string; + classification: Page['classification']; + }; } /** @@ -54,7 +63,7 @@ export class PublicService { if (!pond) throw new NotFoundException(); const page = await this.prisma.page.findFirst({ where: { pondId: pond.id, slug: pageSlug, deletedAt: null }, - select: { id: true, pondId: true, slug: true, title: true }, + select: { id: true, pondId: true, slug: true, title: true, classification: true }, }); // Hide existence: no read access (incl. anonymous without a public grant) → 404. if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) { @@ -80,6 +89,7 @@ export class PublicService { title: page.title, slug: page.slug, html: styleTag + resolveMediaUrls(body), + classification: page.classification.toLowerCase() as PageClassification, updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), }; } diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 97f4e7c..9f08551 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -63,6 +63,24 @@ for (const scheme of SCHEMES) { await context.close(); }); + test(`a classified page shows the marking top+bottom and passes axe (${scheme})`, async ({ + browser, + }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.emulateMedia({ colorScheme: scheme }); + await page.goto('/p/content-fixtures/classified-note'); + await page.waitForLoadState('networkidle'); + // Kennzeichnung oben UND unten (issue #206, ADR 0022) — fester + // Wortlaut, nicht lokalisiert. + const banners = page.locator('.classification-banner'); + await expect(banners).toHaveCount(2); + await expect(banners.first()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + await expect(banners.last()).toContainText('VS – NUR FÜR DEN DIENSTGEBRAUCH'); + await expectClean(page, `Eingestufte Seite classified-note (${scheme})`); + await context.close(); + }); + test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => { const context = await contextForUser(browser, BASE, 'fixture-user'); const page = await context.newPage(); diff --git a/apps/web/src/components/ClassificationBanner.tsx b/apps/web/src/components/ClassificationBanner.tsx new file mode 100644 index 0000000..96bd2a5 --- /dev/null +++ b/apps/web/src/components/ClassificationBanner.tsx @@ -0,0 +1,26 @@ +import { PageClassification, classificationMarking } from '@dorfteich/shared'; +import { useTranslation } from 'react-i18next'; + +/** + * The VS-NfD marking of a page (issue #206, ADR 0022), rendered at the top + * AND bottom of every page representation. The wording comes solely from + * `classificationMarking()` and is deliberately not translated (a marking + * is a fixed formula); only the assistive-tech prefix naming the element is + * localized. Unclassified pages render nothing — marking everything trains + * users to ignore markings (ADR 0022). + */ +export function ClassificationBanner({ + classification, +}: { + classification: PageClassification | undefined; +}): React.JSX.Element | null { + const { t } = useTranslation('common'); + const marking = classification ? classificationMarking(classification) : null; + if (!marking) return null; + return ( +

+ {t('classification.label')}: + {marking} +

+ ); +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index bbc3d76..3137244 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -12,6 +12,7 @@ import * as Y from 'yjs'; import { useAuth } from '../auth/auth-context'; import { CommentsSection } from '../comments/CommentsSection'; +import { ClassificationBanner } from '../components/ClassificationBanner'; import { FormError } from '../components/forms'; import { useToast } from '../components/Toast'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; @@ -563,6 +564,9 @@ export function PageEditorPage(): React.JSX.Element { actionsSlot.element, )}
+ {/* VS-NfD marking above and below the content (issue #206, ADR + 0022) — in reading AND edit mode; unclassified pages show none. */} +
{/* The visible title is an input; give assistive tech the page heading it expects on an article view (#166). */} @@ -610,6 +614,7 @@ export function PageEditorPage(): React.JSX.Element {
)}
+ {/* "Linked from" appears below the content in read mode (issue #48); the inline discussion (issue #133) and the local neighborhood graph (issue #113) follow it, in that order. */} diff --git a/apps/web/src/pages/PublicPageView.tsx b/apps/web/src/pages/PublicPageView.tsx index c5f3cb6..2eaf7a5 100644 --- a/apps/web/src/pages/PublicPageView.tsx +++ b/apps/web/src/pages/PublicPageView.tsx @@ -3,7 +3,10 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import type { PageClassification } from '@dorfteich/shared'; + import { PublicComments } from '../comments/CommentsSection'; +import { ClassificationBanner } from '../components/ClassificationBanner'; import { ApiError, apiGet } from '../lib/api'; import { useDocumentTitle } from '../lib/use-document-title'; import { countWords, htmlToText } from '../lib/word-count'; @@ -16,6 +19,7 @@ interface PublicPageContent { title: string; slug: string; html: string; + classification: PageClassification; updatedAt: string; } @@ -51,6 +55,8 @@ export function PublicPageView(): React.JSX.Element { const page = query.data; return (
+ {/* VS-NfD marking above and below the content (issue #206, ADR 0022). */} +

{t('readOnlyBadge')}

{page.pondName}

{page.title}

@@ -58,6 +64,7 @@ export function PublicPageView(): React.JSX.Element { {/* The HTML comes from the server's content cache (issue #24), derived from the sanitized editor schema — safe to render. */}
+ {/* Existing comments, read-only for anonymous visitors (issue #133). */}
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index ea01521..bfed238 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1476,6 +1476,22 @@ button { color: var(--color-danger); } +/* VS-NfD classification marking (issue #206, ADR 0022): rendered at the top + and bottom of every page representation. Deliberately built from the + normal text token only — full contrast in both themes and under every + accent, with no new color pair for the contrast fence. */ +.classification-banner { + margin: var(--space-2) 0; + padding: var(--space-1) var(--space-2); + border-top: 2px solid currentColor; + border-bottom: 2px solid currentColor; + color: var(--color-text); + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; + font-size: 0.9rem; +} + .editor-page__body { display: flex; gap: var(--space-4); diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index f4078ae..a6c01ac 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -52,13 +52,13 @@ _Meilenstein: `M26 — VS-NfD: classification metadata`_ Instance-Setting · 2 AT · #204 - [x] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 - [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 - - Web-Ansicht (Kopf/Fuß) · 1 AT · #206 - - **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 - - PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 - - DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 - - Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 - - Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 - - Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 + - [x] Web-Ansicht (Kopf/Fuß) · 1 AT · #206 + - [ ] **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 + - [ ] PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 + - [ ] DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 + - [ ] Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 + - [ ] Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 + - [ ] Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 - [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 ### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index a5b9465..9da66f5 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -97,5 +97,8 @@ "settingsNav": { "label": "Abschnitte" }, - "tableActions": "Aktionen" + "tableActions": "Aktionen", + "classification": { + "label": "Einstufung" + } } diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index b1a7f48..4e4e2bb 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -97,5 +97,8 @@ "settingsNav": { "label": "Sections" }, - "tableActions": "Actions" + "tableActions": "Actions", + "classification": { + "label": "Classification" + } }