From 3cd3672c8bb954ec81bc44fabd25c3c2c894fa41 Mon Sep 17 00:00:00 2001 From: "Patrick Seidler (via Claude Code)" Date: Thu, 6 Aug 2026 17:43:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(anleitung):=20Abhol-Seite=20mit=20B=C3=BCr?= =?UTF-8?q?ger-=20und=20Rollentr=C3=A4ger-Spur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setzt die kleine Ausbaustufe aus docs/specs/TUTORIAL_KONZEPT_2026-08.md um. Die Präsentation bleibt unangetastet. Sie ist statisches HTML, laeuft ohne Netz und ohne JavaScript und traegt Sprechnotizen — genau die Eigenschaften, die in einem Rathaus-Termin auf einem fremden Rechner zaehlen und die eine App-Fassung verlieren wuerde. Konsolidiert wird am EINSTIEG, nicht am Artefakt. /anleitung fragt nach der SITUATION, nicht nach der Rolle: Ein Buerger denkt nicht "ich bin Rolle user". Drei Karten — mitmachen, eine Aufgabe haben, Partizip vorstellen. Wer angemeldet ist und eine Rolle traegt, bekommt oben zusaetzlich den direkten Weg in seinen Abschnitt. Bewusst KEINE Folienmechanik fuer die Anleitung: Ein Karussell ist zum Nachschlagen schlecht bedienbar, schlecht zitierbar und ein Problem fuer das a11y-Gate. Stattdessen lange, druckbare Seiten, lueckenlose h1→h2→h3, Nachschlag per
, lesbar ohne JavaScript. Fuer super_admin gibt es bewusst keine oeffentliche Spur — Betreiber-Wissen gehoert in interne Runbooks, nicht in eine Anleitung. Texte liegen als Daten in anleitung-daten.ts, jede fachliche Aussage mit Belegstelle im Code als Kommentar. Drei Korrekturen gegenueber dem Konzept, alle am Code geprueft: die Erinnerung vor Ablauf kommt nach 60 Tagen (nicht 30), der KI-Neutralitaets-Check gated Abstimmungen und nicht Digests, und "Ergebnisse erst nach Ende" gilt nur fuer die Aufschluesselung. Weggelassen, weil am Code nicht bestaetigt: der "6-stellige Code aus derselben Mail" (mail.ts verschickt nur einen Link), die Benachrichtigung bei Rollenaenderung (role-actions.ts versendet keine Mail). Die 2FA-Details tragen TODO(#59) — der Block ist noch nicht gemergt. Einstieg im Footer (einziger dauerhafter Ort — die Landing verschwindet mit dem Region-Cookie), im FAQ-Kopf und als Text-Link unter /aufgaben. Bewusst KEINE Aufgaben-Kachel: aufgabenKacheln spiegelt exakt die Server-Guards, eine rechtefreie Kachel wuerde diese Invariante aufweichen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM --- app/src/app/[tenant]/anleitung/SpurInhalt.tsx | 188 ++++ .../__tests__/anleitung-daten.test.ts | 137 +++ .../app/[tenant]/anleitung/anleitung-daten.ts | 874 ++++++++++++++++++ .../app/[tenant]/anleitung/aufgaben/page.tsx | 106 +++ .../app/[tenant]/anleitung/mitmachen/page.tsx | 52 ++ app/src/app/[tenant]/anleitung/page.tsx | 214 +++++ app/src/app/[tenant]/aufgaben/page.tsx | 12 + app/src/app/[tenant]/faq/page.tsx | 13 + app/src/app/[tenant]/layout.tsx | 11 + app/src/app/globals.css | 46 +- 10 files changed, 1652 insertions(+), 1 deletion(-) create mode 100644 app/src/app/[tenant]/anleitung/SpurInhalt.tsx create mode 100644 app/src/app/[tenant]/anleitung/__tests__/anleitung-daten.test.ts create mode 100644 app/src/app/[tenant]/anleitung/anleitung-daten.ts create mode 100644 app/src/app/[tenant]/anleitung/aufgaben/page.tsx create mode 100644 app/src/app/[tenant]/anleitung/mitmachen/page.tsx create mode 100644 app/src/app/[tenant]/anleitung/page.tsx diff --git a/app/src/app/[tenant]/anleitung/SpurInhalt.tsx b/app/src/app/[tenant]/anleitung/SpurInhalt.tsx new file mode 100644 index 0000000..62b11b2 --- /dev/null +++ b/app/src/app/[tenant]/anleitung/SpurInhalt.tsx @@ -0,0 +1,188 @@ +/** + * SpurInhalt.tsx — rendert EINE Anleitungs-Spur nach dem festen Muster: + * erster Satz → „So läuft es ab" → „Das sollten Sie wissen" → Nachschlag-Fragen + * → weiterführende Links. + * + * SERVER-KOMPONENTE OHNE JavaScript-Bedarf: Die Seiten müssen ohne JS vollständig + * lesbar sein — das ist der Vorteil gegenüber einer Folienmechanik. Deshalb + * natives details/summary statt Akkordeon-Skript, Listen statt Karussell, keine + * Interaktion, die Zustand braucht. + * + * ÜBERSCHRIFTEN-HIERARCHIE: Der Aufrufer gibt über `ebene` an, auf welcher Stufe + * die Abschnitts-Überschriften liegen — 2 auf der Ein-Spur-Seite (h1 ist dort der + * Spur-Titel), 3 auf der Sammelseite (dort ist h2 der Spur-Titel). Damit + * entstehen in beiden Fällen lückenlose Hierarchien (h1 → h2 → h3), was das + * a11y-Gate der CI erwartet. + */ + +import Link from "next/link"; +import type { AnleitungLink, AnleitungSpur } from "./anleitung-daten"; + +/** Tenant-relative Pfade bekommen das Slug-Präfix; `absolut` bleibt unberührt. */ +export function anleitungHref(link: AnleitungLink, slug: string): string { + return link.absolut ? link.href : `/${slug}${link.href}`; +} + +const LINK_KLASSEN = + "font-medium underline-offset-4 hover:underline rounded-sm " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pz-brand)]"; + +/** Ein Sprung-Link in die App — intern über next/link, absolut als normales . */ +function SprungLink({ link, slug }: { link: AnleitungLink; slug: string }) { + const href = anleitungHref(link, slug); + const inhalt = ( + <> + {link.label} + + ); + if (link.absolut) { + return ( + + {inhalt} + + ); + } + return ( + + {inhalt} + + ); +} + +/** + * Abschnitts-Überschrift auf der vom Aufrufer bestimmten Ebene. Bewusst nur h2/h3: + * tiefer wird die Anleitung nicht, und h1 gehört immer der Seite. + */ +function AbschnittTitel({ + ebene, + id, + children, +}: { + ebene: 2 | 3; + id: string; + children: React.ReactNode; +}) { + const klassen = ebene === 2 ? "text-xl font-semibold" : "text-lg font-semibold"; + if (ebene === 2) { + return ( +

+ {children} +

+ ); + } + return ( +

+ {children} +

+ ); +} + +export default function SpurInhalt({ + spur, + slug, + ebene, +}: { + spur: AnleitungSpur; + slug: string; + ebene: 2 | 3; +}) { + return ( + <> +

+ {spur.ersterSatz} +

+ + {/* --- So läuft es ab ------------------------------------------------ */} +
+ + So läuft es ab + + {/* Die Nummerierung kommt aus dem
    (Screenreader lesen sie mit); die + sichtbare Ziffer ist deshalb dekorativ und wird ausgeblendet. */} +
      + {spur.schritte.map((schritt, i) => ( +
    1. + + {i + 1} + +
      +

      + {schritt.titel} +

      +

      + {schritt.text} +

      + {schritt.link && ( +

      + +

      + )} +
      +
    2. + ))} +
    +
+ + {/* --- Das sollten Sie wissen ---------------------------------------- */} +
+ + Das sollten Sie wissen + +
    + {spur.wissen.map((h) => ( +
  • +

    + {h.titel} +

    +

    + {h.text} +

    +
  • + ))} +
+
+ + {/* --- Nachschlag-Fragen (details/summary, ohne JavaScript bedienbar) - */} +
+ + Wenn etwas nicht klappt + +
+ {spur.fragen.map((q) => ( +
+ + {q.f} + +

+ {q.a} +

+
+ ))} +
+
+ + {/* --- Weiterlesen: verlinken statt Inhalte doppelt pflegen ----------- */} + {spur.weiter.length > 0 && ( +
+ + Weiterlesen + +
    + {spur.weiter.map((w) => ( +
  • + +
  • + ))} +
+
+ )} + + ); +} diff --git a/app/src/app/[tenant]/anleitung/__tests__/anleitung-daten.test.ts b/app/src/app/[tenant]/anleitung/__tests__/anleitung-daten.test.ts new file mode 100644 index 0000000..5dbbf69 --- /dev/null +++ b/app/src/app/[tenant]/anleitung/__tests__/anleitung-daten.test.ts @@ -0,0 +1,137 @@ +/** + * anleitung-daten.test.ts — Struktur-Netz für die Anleitungs-Inhalte. + * + * OHNE DATENBANK, ohne Rendering: reine Datenprüfung. Der Zweck ist nicht, + * Formulierungen festzunageln (die soll der Owner frei ändern können), sondern + * die Zusagen der Struktur zu halten: + * + * - Jede Spur hat das vollständige Muster (erster Satz, Schritte, Wissen, + * Fragen) — eine halb gefüllte Spur wäre in der Oberfläche ein Loch. + * - Anker-Ids sind eindeutig und stabil: die Abholseite und das + * Inhaltsverzeichnis springen darauf. + * - Jede real betriebene Rolle findet einen Abschnitt; die Betreiberrolle und + * die Reserve-Rollen finden BEWUSST keinen. + * - Links sind tenant-relativ (führendes „/") — sonst landet ein Klick auf + * dem falschen Mandanten. Einzige Ausnahme: `absolut` (Präsentations-Deck). + */ + +import { describe, it, expect } from "vitest"; +import { + AUFGABEN_SPUREN, + BUERGER_SPUR, + EINSTIEG_KARTEN, + ROLLE_ZU_ABSCHNITT, + abschnitteFuerRollen, + type AnleitungLink, + type AnleitungSpur, +} from "../anleitung-daten"; + +const ALLE_SPUREN: AnleitungSpur[] = [BUERGER_SPUR, ...AUFGABEN_SPUREN]; + +function alleLinks(spur: AnleitungSpur): AnleitungLink[] { + return [ + ...spur.schritte.flatMap((s) => (s.link ? [s.link] : [])), + ...spur.weiter, + ]; +} + +describe("Anleitungs-Daten: Struktur", () => { + it("jede Spur ist vollständig", () => { + for (const spur of ALLE_SPUREN) { + expect(spur.id, "Anker-Id fehlt").toMatch(/^[a-z][a-z-]*$/); + expect(spur.titel.length, `${spur.id}: Titel`).toBeGreaterThan(0); + expect(spur.kurz.length, `${spur.id}: Kurzfassung`).toBeGreaterThan(0); + expect(spur.ersterSatz.length, `${spur.id}: erster Satz`).toBeGreaterThan(0); + expect(spur.schritte.length, `${spur.id}: Schritte`).toBeGreaterThan(0); + expect(spur.wissen.length, `${spur.id}: „Das sollten Sie wissen"`).toBeGreaterThan(0); + expect(spur.fragen.length, `${spur.id}: Nachschlag-Fragen`).toBeGreaterThan(0); + } + }); + + it("Anker-Ids sind eindeutig", () => { + const ids = ALLE_SPUREN.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("Schritt-Titel und Fragen sind je Spur eindeutig (sie dienen als React-key)", () => { + for (const spur of ALLE_SPUREN) { + const titel = spur.schritte.map((s) => s.titel); + expect(new Set(titel).size, `${spur.id}: doppelter Schritt-Titel`).toBe(titel.length); + const fragen = spur.fragen.map((f) => f.f); + expect(new Set(fragen).size, `${spur.id}: doppelte Frage`).toBe(fragen.length); + const wissen = spur.wissen.map((w) => w.titel); + expect(new Set(wissen).size, `${spur.id}: doppelter Hinweis-Titel`).toBe(wissen.length); + } + }); + + it("höchstens ein Sprung-Link je Schritt und alle Links sind tenant-relativ", () => { + for (const spur of ALLE_SPUREN) { + for (const link of alleLinks(spur)) { + expect(link.label.length, `${spur.id}: Link ohne Beschriftung`).toBeGreaterThan(0); + expect(link.href, `${spur.id}: ${link.label}`).toMatch(/^\//); + // Keine absoluten URLs in den Spuren — die Anleitung verlinkt in die App. + expect(link.href.startsWith("//"), `${spur.id}: ${link.label}`).toBe(false); + } + } + }); +}); + +describe("Anleitungs-Daten: Abholseite", () => { + it("bietet genau die drei Situationen an", () => { + expect(EINSTIEG_KARTEN.map((k) => k.key)).toEqual([ + "mitmachen", + "aufgaben", + "vorstellen", + ]); + }); + + it("die Bürger- und die Rollenträger-Karte zeigen auf existierende Routen", () => { + const ziele = EINSTIEG_KARTEN.filter((k) => !k.link.absolut).map((k) => k.link.href); + expect(ziele).toEqual(["/anleitung/mitmachen", "/anleitung/aufgaben"]); + }); + + it("die Präsentation wird absolut verlinkt (liegt außerhalb des Tenant-Routings)", () => { + const deck = EINSTIEG_KARTEN.find((k) => k.key === "vorstellen"); + expect(deck?.link.absolut).toBe(true); + expect(deck?.link.href).toBe("/praesentation"); + }); +}); + +describe("Anleitungs-Daten: Rollen-Zuordnung", () => { + it("jede in Betrieb befindliche Rolle findet einen Abschnitt", () => { + for (const rolle of ["verifier", "redakteur", "kommune_admin", "beobachter"]) { + const treffer = abschnitteFuerRollen([rolle]); + expect(treffer.length, `${rolle} ohne Abschnitt`).toBe(1); + expect(AUFGABEN_SPUREN.map((s) => s.id)).toContain(treffer[0].spurId); + } + }); + + it("Betreiberrolle und Reserve-Rollen bekommen bewusst KEINEN Abschnitt", () => { + for (const rolle of ["super_admin", "ortsteil_admin", "kreis_admin", "land_admin", "user"]) { + expect(abschnitteFuerRollen([rolle]), `${rolle} sollte leer sein`).toEqual([]); + } + }); + + it("Mehrfachrollen ergeben mehrere Abschnitte, ohne Dopplung und in fester Reihenfolge", () => { + const treffer = abschnitteFuerRollen([ + "beobachter", + "verifier", + "verifier", + "unbekannte_rolle", + ]); + expect(treffer.map((t) => t.spurId)).toEqual(["verifizierung", "beobachtung"]); + }); + + it("ohne Rollen (ausgeloggt) gibt es keinen persönlichen Hinweis", () => { + expect(abschnitteFuerRollen([])).toEqual([]); + }); + + it("jede Zuordnung zeigt auf eine existierende Spur", () => { + for (const [rolle, eintrag] of Object.entries(ROLLE_ZU_ABSCHNITT)) { + expect( + AUFGABEN_SPUREN.some((s) => s.id === eintrag.spurId), + `${rolle} → ${eintrag.spurId} existiert nicht`, + ).toBe(true); + } + }); +}); diff --git a/app/src/app/[tenant]/anleitung/anleitung-daten.ts b/app/src/app/[tenant]/anleitung/anleitung-daten.ts new file mode 100644 index 0000000..2cb5b0d --- /dev/null +++ b/app/src/app/[tenant]/anleitung/anleitung-daten.ts @@ -0,0 +1,874 @@ +/** + * anleitung-daten.ts — Inhalte der Anleitung als DATEN (eine Quelle). + * + * Analog zu faq-daten.ts: Texte stehen hier, nicht in den Komponenten. Wer eine + * Formulierung ändern will, fasst ausschließlich diese Datei an — die Seiten + * (page.tsx) rendern nur noch. + * + * AUFBAU je Spur (Konzept TUTORIAL_KONZEPT_2026-08.md, Abschnitt d): + * erster Satz → „So läuft es ab" (nummerierte Schritte, je Schritt HÖCHSTENS + * ein Link in die App) → „Das sollten Sie wissen" → Nachschlag-Fragen. + * + * REGELN FÜR DIESE DATEI (bitte beim Ändern einhalten): + * 1. Jede fachliche Aussage muss am Code belegbar sein. Die Belegstellen + * stehen als Kommentar über der jeweiligen Aussage. Ändert sich der Code, + * ändert sich der Text mit. + * 2. KEINE Screens nachbauen, KEINE Knopf-Positionen beschreiben („grüner + * Knopf links oben"). Die Anleitung erklärt Bedeutung und Reihenfolge und + * verlinkt dann an die echte Stelle — sonst veraltet sie bei jedem + * UI-Feinschliff. + * 3. Sie-Anrede, sachlich, keine Werbesprache. Neutrale Beispiele + * (Wochenmarkt, Spielplatz, Radweg) — nie parteipolitisch Belastetes. + * 4. Keine Doppelpflege: was schon in der FAQ steht, wird verlinkt statt + * wiederholt (`weiter`-Links). + * + * BEWUSST NICHT ENTHALTEN: eine Spur für die Betreiberrolle (`super_admin`). + * Betreiber-Wissen (Bootstrap, Break-Glass, Deploy) gehört in die internen + * Runbooks, nicht in eine öffentliche Anleitung. + */ + +/** + * Ein Link aus der Anleitung heraus. + * + * `href` ist per Konvention TENANT-RELATIV und beginnt mit „/" (die Seite setzt + * `/${slug}` davor). Nur mit `absolut: true` wird der Pfad unverändert benutzt — + * das braucht ausschließlich das statische Präsentations-Deck unter + * `/praesentation` (in der Middleware bewusst vom Tenant-Rewrite ausgenommen). + */ +export interface AnleitungLink { + label: string; + href: string; + /** true ⇒ `href` ist ein absoluter Pfad ohne Tenant-Präfix. */ + absolut?: boolean; +} + +/** Ein nummerierter Schritt in „So läuft es ab". */ +export interface AnleitungSchritt { + titel: string; + text: string; + /** Höchstens EIN Sprung-Link je Schritt (Konzept d). */ + link?: AnleitungLink; +} + +/** Ein Punkt aus „Das sollten Sie wissen" — das, wonach niemand von selbst fragt. */ +export interface AnleitungHinweis { + titel: string; + text: string; +} + +/** Eine Nachschlag-Frage (rendert als details/summary, ohne JavaScript bedienbar). */ +export interface AnleitungFrage { + f: string; + a: string; +} + +/** Eine vollständige Spur. */ +export interface AnleitungSpur { + /** Anker-Id (URL-Fragment) — stabil halten, sie wird verlinkt. */ + id: string; + titel: string; + /** Eine Zeile für Inhaltsverzeichnis und Rollen-Hinweis auf der Abholseite. */ + kurz: string; + ersterSatz: string; + schritte: AnleitungSchritt[]; + wissen: AnleitungHinweis[]; + fragen: AnleitungFrage[]; + /** Weiterführende Ziele (z. B. die bestehende FAQ) — statt Inhalte zu kopieren. */ + weiter: AnleitungLink[]; +} + +// --------------------------------------------------------------------------- +// Abholseite: drei Karten nach der SITUATION, nicht nach dem Rollennamen. +// Bürger denken nicht in Rollen; `beobachter`/`redakteur` sind interne Begriffe. +// --------------------------------------------------------------------------- + +export interface EinstiegKarte { + key: string; + titel: string; + text: string; + link: AnleitungLink; +} + +export const EINSTIEG_KARTEN: EinstiegKarte[] = [ + { + key: "mitmachen", + titel: "Ich möchte mitmachen", + text: + "Sie wohnen hier und wollen bei Fragen Ihrer Kommune mitreden — vom " + + "Wochenmarkt bis zum Radweg. Was Sie dafür brauchen und was mit Ihrer " + + "Stimme passiert.", + link: { label: "Zur Anleitung fürs Mitmachen", href: "/anleitung/mitmachen" }, + }, + { + key: "aufgaben", + titel: "Ich habe eine Aufgabe", + text: + "Sie arbeiten in der Kommune oder bei einer beteiligten Stelle: Wohnsitz " + + "bestätigen, Ratsinfos schreiben, verwalten und freigeben oder mitlesen.", + link: { label: "Zur Anleitung für Rollenträger", href: "/anleitung/aufgaben" }, + }, + { + key: "vorstellen", + titel: "Ich will Partizip vorstellen", + text: + "Sie stellen die Plattform in einer Sitzung oder einem Gespräch vor. Die " + + "Präsentation läuft im Browser, auch ohne Netz, und lässt sich ausdrucken.", + link: { label: "Zur Präsentation für Kommunen", href: "/praesentation", absolut: true }, + }, +]; + +// --------------------------------------------------------------------------- +// c1 — Spur „Mitmachen" (Bürgerinnen und Bürger) +// --------------------------------------------------------------------------- + +export const BUERGER_SPUR: AnleitungSpur = { + id: "mitmachen", + titel: "Mitmachen als Bürgerin oder Bürger", + kurz: "Lesen, mitstimmen, Wohnsitz bestätigen lassen — und was mit Ihrer Stimme passiert.", + ersterSatz: + "Sie können sofort mitmachen — ohne App, ohne Passwort. Wie Sie abgestimmt " + + "haben, erfährt niemand.", + + schritte: [ + { + // Beleg: lib/eligibility/stufe.ts — ohne Konto gilt Stufe 0 (nur Lesen). + titel: "Lesen geht ohne Anmeldung", + text: + "Laufende Abstimmungen, Ergebnisse und die Zusammenfassungen aus dem Rat " + + "sind öffentlich. Dafür brauchen Sie kein Konto.", + link: { label: "Aktuelle Abstimmungen ansehen", href: "/umfragen" }, + }, + { + // Beleg: lib/auth/mail.ts (Link 15 Minuten, einmal verwendbar), + // lib/eligibility/stufe.ts (ohne minAgeConfirmedAt bleibt es Stufe 0), + // datenschutz/page.tsx Ziff. 4 (Session-Cookie höchstens 30 Tage). + titel: "Mit Ihrer E-Mail-Adresse anmelden", + text: + "Sie geben Ihre E-Mail-Adresse an und bekommen einen Anmelde-Link " + + "zugeschickt. Der Link gilt 15 Minuten und lässt sich nur einmal " + + "verwenden; ein Passwort gibt es nicht. Einmalig bestätigen Sie, dass Sie " + + "mindestens 16 Jahre alt sind. Danach bleiben Sie auf diesem Gerät " + + "angemeldet — die Sitzung läuft nach höchstens 30 Tagen ab.", + link: { label: "Anmelden", href: "/anmelden" }, + }, + { + // Beleg: db/schema.ts pollTypeEnum (ja_nein_enthaltung, dot_voting, + // widerstandsabfrage 0–10, geringster Gesamtwiderstand gewinnt), + // PollMitmachen.tsx („Ihre Stimme wurde anonym gezählt"), lib/polls/beleg.ts. + titel: "Abstimmen", + text: + "Es gibt drei Formate: Ja / Nein / Enthaltung; Punkte auf mehrere " + + "Vorschläge verteilen; oder eine Widerstandsabfrage, bei der Sie je " + + "Vorschlag einen Wert von 0 bis 10 vergeben — dort gewinnt der Vorschlag " + + "mit dem geringsten Gesamtwiderstand. Nach dem Absenden wird Ihnen " + + "einmalig ein Beleg-Code angezeigt.", + link: { label: "Zu den Abstimmungen", href: "/umfragen" }, + }, + { + // Beleg: verifizieren/StellenListe.tsx (Walk-in-first, Termin nur bei + // Terminpflicht), verifizieren/MeinKontoQr.tsx (Beleg aus dem eigenen + // Konto, Klartext-Code als Rückfallweg), lib/verification/proof-core.ts. + titel: "Wohnsitz bestätigen lassen", + text: + "Für verbindliche Abstimmungen muss bestätigt sein, dass Sie hier wohnen. " + + "Sie wählen eine Stelle in Ihrer Nähe; die meisten sind während der " + + "Öffnungszeiten ohne Termin erreichbar, einzelne verlangen einen Termin. " + + "Vor Ort weisen Sie sich aus und zeigen den Beleg aus Ihrem Konto. " + + "Gespeichert wird nur, dass Ihr Wohnsitz bestätigt wurde — nichts vom Ausweis.", + link: { label: "Stelle in Ihrer Nähe finden", href: "/verifizieren" }, + }, + { + // Beleg: konto/page.tsx mit EmailAendernSection, WohnortSection, + // BenachrichtigungSection, KontoLoeschenSection, konto/export/route.ts. + titel: "Ihr Konto gehört Ihnen", + text: + "E-Mail-Adresse ändern, Ortsteil hinterlegen, Benachrichtigungen bei " + + "neuen Abstimmungen ein- und ausschalten, alle zu Ihnen gespeicherten " + + "Daten als Datei herunterladen, Konto löschen — alles selbst, ohne Anfrage " + + "bei der Verwaltung.", + link: { label: "Mein Konto öffnen", href: "/konto" }, + }, + ], + + wissen: [ + { + // Beleg: lib/verification/qr-core.ts QR_VERIFICATION_MONTHS = 24; + // lib/verification/reverify-reminders.ts DEFAULT_REVERIFY_WINDOW_DAYS = 60; + // lib/eligibility/stufe.ts (residencyVerifiedUntil abgelaufen ⇒ Stufe 1). + titel: "Die Wohnsitz-Bestätigung läuft nach 24 Monaten ab", + text: + "Rund zwei Monate vorher erinnert Sie eine E-Mail. Läuft die Bestätigung " + + "ab, fällt Ihr Konto still eine Stufe zurück: mitstimmen weiterhin ja, " + + "verbindlich abstimmen nein. Das ist kein Fehler, sondern so gewollt — Sie " + + "lassen den Wohnsitz einfach neu bestätigen.", + }, + { + // Beleg: lib/polls/voter-ref.ts (HMAC-Pseudonym statt Konto-Bezug, Salt + // verlässt den Server nie; im Audit steht ausschließlich der voter_ref). + titel: "Ihre Wahl steht in keinem Protokoll", + text: + "An einer Stimme hängt keine Kennung Ihres Kontos, sondern ein Pseudonym, " + + "das der Server mit einem geheimen Schlüssel berechnet. Die Verwaltung " + + "sieht Ergebnisse, nie einzelne Stimmen; in den technischen Protokollen " + + "steht ausschließlich dieses Pseudonym.", + }, + { + // Beleg: lib/polls/ergebnis.ts K_ANONYMITY_SCHWELLE = 5, serverseitige + // Suppression; ADR-022: Aufschlüsselung erst nach Abstimmungsende. + titel: "Kleine Gruppen werden nicht ausgewiesen", + text: + "Antwortoptionen mit weniger als fünf Stimmen macht schon der Server " + + "unkenntlich — sonst ließe sich zurückrechnen, wer wie gestimmt hat. " + + "Deshalb steht an manchen Stellen, dass es für eine Anzeige zu wenige " + + "Stimmen sind. Solange eine Abstimmung läuft, sehen Sie nur die " + + "Gesamtzahlen; die Aufschlüsselung nach Antworten gibt es nach dem Ende.", + }, + { + // Beleg: lib/polls/ergebnis.ts — zwei Signale (gesamt / verifiziert). + titel: "Zwei Zahlen, beide echt", + text: + "Ergebnisse weisen die Gesamtzahl der Stimmen und die Zahl der " + + "wohnsitzverifizierten Stimmen getrennt aus. Das erste ist ein " + + "Stimmungsbild, das zweite ist an den bestätigten Wohnsitz gebunden.", + }, + { + // Beleg: faq-daten.ts (kostenlos, kein Passwort); lib/auth/mail.ts. + titel: "Es kostet nichts und es ruft niemand an", + text: + "Die Teilnahme ist für Bürgerinnen und Bürger kostenlos. Es gibt kein " + + "Passwort, das Sie vergessen könnten, und keine telefonische Nachfrage.", + }, + ], + + fragen: [ + { + // Beleg: lib/polls/beleg.ts (Beleg beweist DASS, nie WIE); + // umfrage/[id]/belege/page.tsx (öffentliche Liste nach Ende); + // PollMitmachen.tsx (Anzeige nur bei frischer Stimme, nie nachladbar). + f: "Wozu ist der Beleg-Code nach dem Abstimmen gut?", + a: + "Er belegt, dass Ihre Stimme mitgezählt wurde — nicht, wie Sie gestimmt " + + "haben. Nach dem Ende der Abstimmung finden Sie ihn in der öffentlichen " + + "Liste der Belege wieder. Der Code wird genau einmal angezeigt und lässt " + + "sich später nicht erneut abrufen; notieren Sie ihn, wenn Sie ihn behalten " + + "möchten.", + }, + { + // Beleg: lib/auth/mail.ts (15 Minuten, einmal verwendbar). + f: "Der Anmelde-Link ist nicht angekommen oder gilt nicht mehr.", + a: + "Sehen Sie zuerst im Spam-Ordner nach. Der Link gilt 15 Minuten und lässt " + + "sich nur einmal verwenden — danach fordern Sie auf der Anmelde-Seite " + + "einfach einen neuen an.", + }, + { + // Beleg: db/schema.ts polls.verbindlich („nur Stufe≥2 dürfen abstimmen"). + f: "Muss ich meinen Wohnsitz bestätigen lassen, um mitzumachen?", + a: + "Nein. Lesen geht ohne Konto, Mitstimmen mit E-Mail-Adresse. Die " + + "Wohnsitz-Bestätigung brauchen Sie nur für Abstimmungen, die als " + + "verbindlich gekennzeichnet sind.", + }, + { + // Beleg: lib/verification/proof-core.ts PROOF_TTL_MIN = 5, Single-Use; + // MeinKontoQr.tsx (Klartext-Code als Rückfallweg, „neu erzeugen"). + f: "Mein Beleg für die Stelle vor Ort ist abgelaufen.", + a: + "Der Beleg ist absichtlich nur wenige Minuten gültig und lässt sich nur " + + "einmal einlösen. Erzeugen Sie ihn in Ihrem Konto einfach neu. Falls die " + + "Kamera den QR-Code nicht liest, steht darunter derselbe Code zum Vorlesen " + + "oder Eintippen.", + }, + { + // Beleg: anliegen/page.tsx (öffentlicher Tracker, Stufe 0, Status per Code). + f: "Wie erfahre ich, was aus einem eingereichten Anliegen geworden ist?", + a: + "Über den Tracking-Code, den Sie beim Einreichen erhalten haben. Damit " + + "rufen Sie den Bearbeitungsstand ab, ohne sich anzumelden.", + }, + ], + + weiter: [ + { label: "Häufige Fragen (Kurzfassung)", href: "/faq" }, + { label: "Datenschutzerklärung", href: "/datenschutz" }, + { label: "Anliegen mit Code verfolgen", href: "/anliegen" }, + ], +}; + +// --------------------------------------------------------------------------- +// c2–c5 — Spuren der Rollenträger. Öffentlich lesbar (Owner-Entscheidung): +// die Guards sitzen an den echten Flächen, nicht an der Dokumentation, und +// „jeder kann nachlesen, was eine Stelle darf und was sie nicht speichert" ist +// ein Transparenz-Argument, das zum Produkt passt. +// --------------------------------------------------------------------------- + +const VERIFIZIERUNG_SPUR: AnleitungSpur = { + id: "verifizierung", + titel: "Wohnsitz bestätigen (Verifizierung)", + kurz: "Sie bestätigen vor Ort, dass eine Person hier wohnt.", + ersterSatz: + "Sie bestätigen, dass eine Person hier wohnt — mehr prüfen Sie nicht, und " + + "mehr wird auch nicht gespeichert.", + + schritte: [ + { + // Beleg: lib/admin/invitation-core.ts (Einladung, Standard 14 Tage); + // Anmeldung über denselben Magic-Link wie für alle (lib/auth/mail.ts). + titel: "Einladung annehmen und anmelden", + text: + "Sie werden per E-Mail eingeladen und melden sich mit demselben " + + "Anmelde-Link an wie alle anderen. Eine Einladung gilt standardmäßig " + + "14 Tage; danach lässt sie sich neu versenden.", + link: { label: "Anmelden", href: "/anmelden" }, + }, + { + // Beleg: [tenant]/aufgaben/page.tsx + lib/aufgaben/kacheln.ts — angezeigt + // wird ausschließlich, wofür der Server die Person auch berechtigt. + titel: "Aufgaben öffnen", + text: + "Nach der Anmeldung führt die Ansicht „Aufgaben“ zu Ihren Funktionen. " + + "Dort steht nur, wofür Sie tatsächlich berechtigt sind.", + link: { label: "Zu den Aufgaben", href: "/aufgaben" }, + }, + { + // Beleg: lib/verification/proof-core.ts (umgekehrter Konto-Beleg V3), + // lib/aufgaben/kacheln.ts Kachel „verifizieren", + // lib/verification/qr-core.ts QR_VERIFICATION_MONTHS = 24. + titel: "Vor Ort bestätigen", + text: + "Die Person weist sich mit ihrem Ausweis aus und zeigt den Beleg aus " + + "ihrem eigenen Konto — als QR-Code oder als Code zum Eintippen. Sie " + + "erfassen ihn und bestätigen. Damit gilt der Wohnsitz für 24 Monate als " + + "bestätigt.", + link: { label: "Bestätigung öffnen", href: "/verifizieren/bestaetigen" }, + }, + { + // Beleg: lib/aufgaben/kacheln.ts Kachel „termine" → /admin/verifizierung. + titel: "Termine und Aktivität Ihrer Stelle", + text: + "Gebuchte Termine bestätigen und nachsehen, was an Ihrer Stelle " + + "geschehen ist.", + link: { label: "Verifizierung öffnen", href: "/admin/verifizierung" }, + }, + ], + + wissen: [ + { + // Beleg: lib/verification/proof-core.ts — gespeichert wird ausschließlich + // die Bestätigung; es gibt keinen Melderegister-Abgleich (faq-daten.ts). + titel: "Was Sie ausdrücklich nicht tun", + text: + "Ausweisdaten werden nicht abgetippt, nicht kopiert, nicht fotografiert " + + "und mit keinem Register abgeglichen. Der Ausweis dient allein dem " + + "Abgleich vor Ort. Gespeichert wird ausschließlich die Bestätigung.", + }, + { + // Beleg: lib/verification/proof-core.ts — „Der Verifizierer sieht die + // Bürger-Identität NIE (die user_id ist nur interner Anker)". + titel: "Sie sehen die Kontodaten der Person nicht", + text: + "Der Beleg enthält keine Angaben zum Konto. Ihre Bestätigung wirkt auf " + + "das richtige Konto, ohne dass Ihnen dessen Daten angezeigt werden.", + }, + { + // Beleg: lib/verification/proof-core.ts ProofGebietError + pfadDecktAb; + // lib/auth/roles.ts erlaubteScopeEbenenFuerVerifier (UI ist nur Komfort). + titel: "Gebietsbindung: nur Ihr Zuständigkeitsgebiet", + text: + "Sie können ausschließlich für Ihr eigenes Gebiet bestätigen. Das " + + "erzwingt der Server; die Auswahl in der Oberfläche zeigt Ihnen ohnehin " + + "nur Erlaubtes.", + }, + { + // Beleg: lib/verification/proof-core.ts — „Kein Selbst-Hochstufen", + // doppelt geprüft (Vorab + auf dem RETURNING-userId). + titel: "Den eigenen Beleg können Sie nicht bestätigen", + text: + "Wer selbst eine Bestätigung braucht, geht wie alle anderen zu einer " + + "Stelle. Der Server weist die Selbstbestätigung ab.", + }, + { + // Beleg: lib/verification/proof-core.ts PROOF_TTL_MIN = 5, Single-Use. + titel: "Wenn der Beleg nicht mehr gilt", + text: + "Ein Beleg ist bewusst nur wenige Minuten gültig und einmal einlösbar. " + + "Bitten Sie die Person, ihn in ihrem Konto neu zu erzeugen — improvisieren " + + "Sie nichts und notieren Sie keine Ausweisdaten.", + }, + ], + + fragen: [ + { + // Beleg: lib/auth/roles.ts pfadDecktAb / VERIFIER_ROLES. + f: "Darf ich jemanden aus dem Nachbarort bestätigen?", + a: + "Nein. Ihre Rolle hängt an einem Gebietsknoten und deckt nur diesen und " + + "alles darunter ab. Eine Bestätigung außerhalb weist der Server ab.", + }, + { + // Beleg: lib/verification/qr-core.ts QR_VERIFICATION_MONTHS, + // lib/verification/reverify-reminders.ts (Erinnerung im 60-Tage-Fenster). + f: "Was passiert nach den 24 Monaten?", + a: + "Die Person wird rund zwei Monate vorher per E-Mail erinnert und kommt " + + "einmal wieder vorbei. Bis dahin kann sie unverändert mitstimmen, nur " + + "verbindliche Abstimmungen sind dann gesperrt.", + }, + { + // Beleg: [tenant]/anleitung/mitmachen — Gegenseite desselben Vorgangs. + f: "Jemand fragt, wie das aus seiner Sicht abläuft.", + a: + "Die Bürger-Anleitung beschreibt genau die Gegenseite: Konto anlegen, " + + "Stelle wählen, Beleg zeigen. Sie ist öffentlich und lässt sich ausdrucken.", + }, + ], + + weiter: [ + { label: "Anleitung fürs Mitmachen (die Gegenseite)", href: "/anleitung/mitmachen" }, + { label: "Verifizierungs-Stellen dieser Kommune", href: "/verifizieren" }, + ], +}; + +const REDAKTION_SPUR: AnleitungSpur = { + id: "redaktion", + titel: "Ratsinfos schreiben (Redaktion)", + kurz: "Sie bereiten Sitzungszusammenfassungen auf und prüfen die Quellen.", + ersterSatz: + "Sie machen Ratsarbeit verständlich — jede Aussage mit Quelle, und " + + "veröffentlicht wird nie ohne ein zweites Paar Augen.", + + schritte: [ + { + titel: "Einladung annehmen und anmelden", + text: + "Auch für Rollen gibt es kein Passwort: Sie melden sich mit dem " + + "Anmelde-Link aus Ihrer E-Mail an.", + link: { label: "Anmelden", href: "/anmelden" }, + }, + { + titel: "Aufgaben öffnen", + text: "Die Ansicht „Aufgaben“ führt zu Ihren Funktionen.", + link: { label: "Zu den Aufgaben", href: "/aufgaben" }, + }, + { + // Beleg: lib/digest/freigabe-core.ts (Aussagen mit geprueft_by / + // highlighted_by), lib/digest/extractive_v1.ts („Jede Aussage MUSS eine + // sourceDocumentId haben — keine Aussage ohne Quelle"). + titel: "Entwurf bearbeiten und Quellen prüfen", + text: + "Ein Digest besteht aus einzelnen Aussagen, jede mit ihrer Fundstelle. " + + "Sie bearbeiten den Entwurf, gewichten Aussagen und haken sie ab, wenn Sie " + + "die Quelle geprüft haben.", + link: { label: "Ratsinfos öffnen", href: "/admin/digests" }, + }, + { + // Beleg: lib/auth/roles.ts FREIGABE_ROLES (nur Admin-Rollen). + titel: "An die Freigabe übergeben", + text: + "Ist jede Aussage geprüft, übernimmt eine Person mit Verwaltungsrolle die " + + "Freigabe. Damit endet Ihr Teil des Vorgangs.", + }, + ], + + wissen: [ + { + // Beleg: lib/auth/roles.ts canFreigeben (redakteur ausgeschlossen); + // lib/digest/freigabe-core.ts (SoD-Sperre atomar im UPDATE, fail-closed). + titel: "Vier-Augen-Prinzip, serverseitig hart", + text: + "Sie dürfen prüfen, gewichten und bearbeiten — freigeben und " + + "veröffentlichen können ausschließlich Verwaltungsrollen. Und auch dort " + + "gilt: Wer an einem Digest mitgewirkt hat, kann ihn nicht selbst " + + "freigeben. Die Sperre steckt in derselben Datenbank-Operation wie der " + + "Statuswechsel, sie lässt sich nicht umgehen.", + }, + { + // Beleg: db/schema.ts digestStatusEnum (entwurf/freigegeben/veroeffentlicht), + // digests.approvedContentHash (bei Veröffentlichung verglichen). + titel: "Drei Zustände, und die Freigabe versiegelt", + text: + "Entwurf → freigegeben → veröffentlicht. Mit der Freigabe wird der Inhalt " + + "über eine Prüfsumme festgeschrieben. Ändert sich danach etwas, muss neu " + + "freigegeben werden — stillschweigende Korrekturen sind ausgeschlossen.", + }, + { + // Beleg: lib/digest/extractive_v1.ts (Neutralitätskodex: nur Tatsachen aus + // den Dokumenten, keine Bewertungen, keine Parteinennung), + // lib/digest/llm_v2.ts (keine Wertungen, keine Spekulation, nur Inhalte + // aus den übergebenen Dokumenten). + titel: "Neutralitätskodex in drei Zeilen", + text: + "Nur Tatsachen, die in den Sitzungsunterlagen belegt sind. Keine " + + "Bewertung, keine Spekulation, keine Parteinennung. Trennen Sie Tatsache " + + "und Einordnung sichtbar — im Zweifel lieber keine Aussage als eine " + + "möglicherweise falsche.", + }, + { + // Beleg: lib/digest/extractive_v1.ts (RIEGEL bei TOP-Dokumenten: lieber + // keine Aussage als eine möglicherweise falsche Abstimmungszuordnung). + titel: "Automatisch erzeugte Entwürfe sind Entwürfe", + text: + "Entwürfe entstehen aus den Sitzungsunterlagen, teils maschinell. Sie " + + "sind Rohmaterial: Prüfen Sie jede Aussage gegen ihre Fundstelle, " + + "insbesondere Zahlen und Abstimmungsergebnisse.", + }, + ], + + fragen: [ + { + // Beleg: lib/auth/roles.ts REDAKTION_ROLES vs. FREIGABE_ROLES. + f: "Warum kann ich nichts veröffentlichen?", + a: + "Das ist beabsichtigt und in den Rechten der Rolle festgelegt. Redaktion " + + "und Freigabe sind getrennt, damit kein Text ohne zweite Prüfung " + + "öffentlich wird.", + }, + { + // Beleg: db/schema.ts digests.approvedContentHash — bei Veröffentlichung + // gegen den Freigabe-Stand verglichen. + f: "Ich habe nach der Freigabe einen Fehler entdeckt.", + a: + "Melden Sie ihn der freigebenden Person. Der Inhalt lässt sich ändern, " + + "aber die Änderung braucht zwingend eine neue Freigabe — der " + + "Veröffentlichungs-Schritt vergleicht den Text mit dem freigegebenen Stand.", + }, + { + // Beleg: [tenant]/transparenz/page.tsx (veröffentlichte Digests mit + // Freigabe- und Veröffentlichungszeitpunkt). + f: "Wo sehen Außenstehende, was freigegeben wurde?", + a: + "Auf der Transparenz-Seite dieser Kommune. Dort stehen die " + + "veröffentlichten Zusammenfassungen mit ihrem Freigabe-Zeitpunkt.", + }, + ], + + weiter: [ + { label: "Transparenz: Freigaben und Korrekturen", href: "/transparenz" }, + ], +}; + +const ADMINISTRATION_SPUR: AnleitungSpur = { + id: "administration", + titel: "Verwalten und freigeben (Administration)", + kurz: "Sie stellen Fragen, geben frei, vergeben Rollen und pflegen die Stellen.", + ersterSatz: + "Sie stellen die Fragen Ihrer Kommune — und tragen die Verantwortung für " + + "alles, was veröffentlicht wird.", + + schritte: [ + { + // Beleg: admin/page.tsx „Erste Schritte" — Standort ist der erste Baustein; + // lib/aufgaben/kacheln.ts Kachel „standorte"; + // verifizieren/StellenListe.tsx zeigt genau diese Standorte. + titel: "Zuerst: Standorte und Sprechzeiten pflegen", + text: + "Davon hängt ab, was Bürgerinnen und Bürger unter „Stellen in Ihrer Nähe“ " + + "sehen. Ohne gepflegten Standort läuft die Wohnsitz-Bestätigung ins Leere.", + link: { label: "Standorte pflegen", href: "/admin/verifizierung/standorte" }, + }, + { + // Beleg: lib/aufgaben/kacheln.ts Kachel „umfrage"; db/schema.ts + // pollStatusEnum (entwurf → aktiv → geschlossen) und polls.verbindlich. + titel: "Abstimmung erstellen", + text: + "Format und Gebiet wählen — das Gebiet entscheidet, wen die Frage " + + "erreicht. Eine Abstimmung beginnt als Entwurf und geht erst durch einen " + + "eigenen Schritt live. Nach Ablauf der Frist schließen Sie sie; erst dann " + + "steht das Ergebnis mit der Aufschlüsselung nach Antworten fest.", + link: { label: "Abstimmungen verwalten", href: "/admin/abstimmungen" }, + }, + { + // Beleg: lib/auth/roles.ts FREIGABE_ROLES; lib/digest/freigabe-core.ts. + titel: "Digests freigeben", + text: + "Sie sind das zweite Augenpaar. Prüfen Sie die Quellenlinks, nicht nur " + + "den Text — mit der Freigabe wird der Inhalt festgeschrieben.", + link: { label: "Ratsinfos öffnen", href: "/admin/digests" }, + }, + { + // Beleg: lib/admin/invitation-core.ts (Einladung statt Konto-Anlage). + titel: "Rollen vergeben", + text: + "Sie legen keine Konten an, sondern laden per E-Mail ein. Die Person " + + "meldet sich selbst an und erhält damit die Rolle.", + link: { label: "Rollen verwalten", href: "/admin/rollen" }, + }, + { + // Beleg: admin/page.tsx Karte „Protokoll" („PII-frei, ohne E-Mail"); + // lib/admin/role-actions.ts (Audit-metadata enthält NIEMALS E-Mail). + titel: "Protokoll einsehen", + text: + "Alles Folgenreiche wird protokolliert — ohne personenbezogene Daten. Das " + + "Protokoll ist Ihr Beleg gegenüber Rat und Öffentlichkeit.", + link: { label: "Protokoll öffnen", href: "/admin/protokoll" }, + }, + ], + + wissen: [ + { + // Beleg: lib/auth/roles.ts KOMMUNE_ADMIN_MANAGEABLE_ROLES + canManageRole + // (niemals super_admin, niemals die Reserve-Rollen). + titel: "Eskalationsgrenze", + text: + "Sie können die Rollen für Bürgerkonten, Verifizierung, Redaktion, " + + "Beobachtung und Verwaltung vergeben und entziehen — die Betreiberrolle " + + "niemals. Diese Grenze prüft der Server bei jeder Änderung neu.", + }, + { + // Beleg: lib/polls/voter-ref.ts + lib/polls/ergebnis.ts — es gibt keine + // Fläche, die Person und Wahl verbindet; Ergebnisse sind Aggregate. + titel: "Was auch Sie nicht sehen", + text: + "Wie eine einzelne Person abgestimmt hat, zeigt Ihnen die Anwendung an " + + "keiner Stelle — nicht in Ergebnissen, nicht in Auswertungen, nicht im " + + "Protokoll. Das ist der Satz, den Sie Rat, Bürgern und Presse ruhig sagen " + + "können.", + }, + { + // Beleg: lib/digest/freigabe-core.ts (SoD auch für Admins; + // ALLOW_SELF_APPROVAL nur als auditierte Pilot-Überbrückung, + // metadata.selfApproval = true). + titel: "Das Vier-Augen-Prinzip gilt auch für Sie", + text: + "Haben Sie an einem Digest selbst Aussagen geprüft oder gewichtet, können " + + "Sie ihn nicht freigeben. Für den Betrieb mit nur einer Person kann der " + + "Betreiber diese Sperre ausdrücklich überbrücken; jede solche Freigabe " + + "wird im Protokoll als Selbstfreigabe vermerkt — sie ist nie unsichtbar.", + }, + { + // Beleg: db/schema.ts polls.verbindlich („nur Stufe≥2 dürfen abstimmen"). + titel: "„Verbindlich“ ist eine Entscheidung mit Folgen", + text: + "Markieren Sie eine Abstimmung als verbindlich, dürfen nur Personen mit " + + "bestätigtem Wohnsitz mitstimmen. Ohne diese Markierung ist das Ergebnis " + + "ein Stimmungsbild — beides ist legitim, aber es sollte bewusst gewählt sein.", + }, + { + // Beleg: lib/polls/pruefung-core.ts + lib/ki/neutralitaet-prompt.ts + + // transparenz/page.tsx (öffentliches Log). Pro Kommune aktivierbar. + titel: "Wenn der Neutralitäts-Check aktiv ist", + text: + "Ist er für Ihre Kommune eingeschaltet, geht eine aktivierte Abstimmung " + + "zunächst in Prüfung statt sofort live. Die Prüfung kann anhalten, nie " + + "endgültig ablehnen — im Zweifel wird zugelassen, und die letzte Instanz " + + "ist ein Mensch. Prüf-Prompt und Ergebnis stehen öffentlich auf der " + + "Transparenz-Seite.", + }, + { + // Beleg: lib/auth/roles.ts getUserRoleTypes — innerer JOIN auf + // users.account_status='active'; gesperrte Konten erhalten []. + titel: "Ein gesperrtes Konto verliert sofort alle Rechte", + text: + "Auch bei noch laufender Sitzung: Die Rechteprüfung lädt ausschließlich " + + "Rollen aktiver Konten. Eine Sperre wirkt damit ohne Wartezeit.", + }, + { + // TODO(#59): Zwei-Faktor-Pflicht für Admin-Rollen liegt auf dem noch nicht + // gemergten Branch `feat/59-admin-2fa` (lib/auth/zwei-faktor.ts, + // [tenant]/konto/zwei-faktor/). Dieser Abschnitt beschreibt daher NUR den + // heutigen, belegten Stand (kein 2FA im Code) plus die öffentlich + // dokumentierte Roadmap-Absicht (ROADMAP.md Z. 86; ADR-017 Punkt 4; + // Deck-Folie 10 „GEPLANT"). BEIM MERGE VON #59 GEGEN DEN DANN REALEN + // STAND PRÜFEN und ergänzen: Einrichtung unter /konto/zwei-faktor, + // Wiederherstellungscodes, Step-up-Fristen, Kulanzfrist. Nichts davon + // hier vorwegnehmen, solange es nicht im Code steht. + titel: "Anmeldung heute — und was dazukommen soll", + text: + "Heute melden Sie sich wie alle anderen mit dem Anmelde-Link aus Ihrer " + + "E-Mail an; ein Passwort gibt es nicht. Eine Zwei-Faktor-Anmeldung für " + + "Verwaltungsrollen steht auf der Roadmap. Sie ist ausdrücklich nur für " + + "Verwaltungsrollen vorgesehen — Verifizierung, Redaktion, Beobachtung und " + + "Bürgerkonten bleiben davon unberührt.", + }, + ], + + fragen: [ + { + // Beleg: lib/auth/roles.ts canManageRole — super_admin nie vergebbar. + f: "Ich finde die Betreiberrolle nicht in der Auswahl.", + a: + "Richtig so. Die Betreiberrolle lässt sich von einer Verwaltungsrolle aus " + + "weder vergeben noch entziehen. Sie ist die Rolle des Plattform-Betreibers, " + + "nicht der Kommune.", + }, + { + // Beleg: lib/digest/freigabe-core.ts SOD_FEHLER. + f: "Die Freigabe wird mit Hinweis auf das Vier-Augen-Prinzip abgelehnt.", + a: + "Dann haben Sie an diesem Digest selbst mitgewirkt — mindestens eine " + + "Aussage geprüft oder gewichtet. Die Freigabe muss eine zweite Person " + + "übernehmen.", + }, + { + // Beleg: lib/polls/ergebnis.ts (ADR-022: Aufschlüsselung erst nach Ende). + f: "Warum sehe ich während einer laufenden Abstimmung keine Aufschlüsselung?", + a: + "Weil sich aus wiederholten Zwischenständen kleine Gruppen zurückrechnen " + + "ließen. Während der Laufzeit gibt es nur Gesamtzahlen; die Aufschlüsselung " + + "nach Antworten erscheint als ein Stand nach dem Ende.", + }, + { + // Beleg: lib/polls/notify.ts — E-Mail an Opt-in-Konten im Gebiet der Umfrage. + f: "Erfahren die Leute, dass es etwas Neues gibt?", + a: + "Wer Benachrichtigungen eingeschaltet hat und im Gebiet der Abstimmung " + + "wohnt, bekommt beim Live-Schalten eine E-Mail. Das ist ein Opt-in, keine " + + "automatische Verteilung.", + }, + ], + + weiter: [ + { label: "Transparenz-Seite dieser Kommune", href: "/transparenz" }, + { label: "Anleitung fürs Mitmachen (was Bürger sehen)", href: "/anleitung/mitmachen" }, + ], +}; + +const BEOBACHTUNG_SPUR: AnleitungSpur = { + id: "beobachtung", + titel: "Nur mitlesen (Beobachtung)", + kurz: "Sie sehen Ihr Gebiet — ändern können Sie nichts.", + ersterSatz: + "Sie sehen, was in Ihrem Gebiet läuft — ändern können Sie nichts, und genau " + + "das ist der Sinn dieser Rolle.", + + schritte: [ + { + titel: "Einladung annehmen und anmelden", + text: "Auch hier ohne Passwort: Anmelde-Link aus der E-Mail.", + link: { label: "Anmelden", href: "/anmelden" }, + }, + { + titel: "Aufgaben öffnen", + text: "Die Ansicht „Aufgaben“ führt zu Ihren beiden Lese-Einstiegen.", + link: { label: "Zu den Aufgaben", href: "/aufgaben" }, + }, + { + // Beleg: lib/aufgaben/kacheln.ts Kachel „abstimmungen-lese". + titel: "Abstimmungen einsehen", + text: + "Laufende und beendete Abstimmungen in Ihrem Gebiet, mit Ergebnissen — " + + "in einer reinen Lese-Ansicht.", + link: { label: "Abstimmungen einsehen", href: "/admin/abstimmungen" }, + }, + { + // Beleg: lib/aufgaben/kacheln.ts Kachel „uebersicht"; admin/page.tsx + // (Beobachter ohne Kennzahlen, Digest-Karte nur bei stadtweitem Gebiet). + titel: "Übersicht öffnen", + text: + "Die zusammenfassende Lese-Sicht. Kennzahlen und Verwaltungsfunktionen " + + "sehen Sie dort bewusst nicht.", + link: { label: "Übersicht öffnen", href: "/admin" }, + }, + ], + + wissen: [ + { + // Beleg: lib/auth/roles.ts — `beobachter` taucht in KEINER Mutations-Achse + // auf (REDAKTION/FREIGABE/ADMIN/VERIFIER). + titel: "Keinerlei Schreibrechte, mit Absicht", + text: + "Keine Freigaben, keine Rollenvergabe, keine Verifizierung, keine " + + "Bearbeitung. Die Rolle ist für Multiplikatoren gedacht, die Ergebnisse " + + "weitertragen — nicht für Mitarbeit.", + }, + { + // Beleg: lib/auth/roles.ts beobachterDarfSehen / pfadDecktAb + // (Vorfahr-oder-Selbst im Gebietsbaum). + titel: "Ihr Gebiet und alles darunter", + text: + "Eine Beobachterrolle auf Kreisebene sieht die Gemeinden darunter. Eine " + + "Rolle für einen Ortsteil sieht nur diesen Ortsteil — keine Nachbarorte " + + "und nichts Stadtweites.", + }, + { + // Beleg: lib/auth/roles.ts beobachterDarfTenantweitSehen (fail-closed für + // reine Ortsteil-Knoten); admin/page.tsx zeigeDigestKarte. + titel: "Stadtweite Entwürfe nur mit stadtweitem Gebiet", + text: + "Digest-Entwürfe gelten für die ganze Kommune. Wer nur für einen Ortsteil " + + "eingetragen ist, sieht sie deshalb nicht — das ist keine Störung.", + }, + { + // Beleg: lib/polls/ergebnis.ts — die Suppression wirkt serverseitig für alle. + titel: "Die Maskierung gilt auch für Sie", + text: + "Was zum Schutz kleiner Gruppen unkenntlich gemacht ist, ist für alle " + + "unkenntlich — es gibt keine Lese-Rolle, die daran vorbeisieht.", + }, + ], + + fragen: [ + { + // Beleg: lib/aufgaben/kacheln.ts (Kachel-Sichtbarkeit spiegelt die Guards). + f: "Mir fehlt eine Funktion, die Kollegen haben.", + a: + "Angezeigt wird genau das, wofür der Server Sie berechtigt. Fehlt etwas " + + "dauerhaft, ist die Rolle gemeint — wenden Sie sich an die Verwaltung " + + "Ihrer Kommune.", + }, + { + // Beleg: lib/auth/roles.ts pfadDecktAb. + f: "Kann mein Gebiet erweitert werden?", + a: + "Ja, über eine zusätzliche oder andere Rollenzuweisung durch die " + + "Verwaltung. Die Sichtbarkeit folgt immer dem Gebietsknoten Ihrer Rolle.", + }, + ], + + weiter: [ + { label: "Transparenz-Seite dieser Kommune", href: "/transparenz" }, + ], +}; + +/** + * Reihenfolge der Abschnitte auf `/anleitung/aufgaben` — von der häufigsten + * Aufgabe (Verifizierung, der v1-Fokus) zur reinen Lese-Rolle. Die Ids sind + * Anker und werden von der Abholseite verlinkt: nicht ohne Grund ändern. + */ +export const AUFGABEN_SPUREN: AnleitungSpur[] = [ + VERIFIZIERUNG_SPUR, + REDAKTION_SPUR, + ADMINISTRATION_SPUR, + BEOBACHTUNG_SPUR, +]; + +/** + * Rollentyp → passender Abschnitt, für den persönlichen Hinweis auf der + * Abholseite („Sie sind als … eingetragen"). + * + * Deckt exakt die Rollen ab, die im Betrieb sind (roleTypeEnum in schema.ts). + * BEWUSST NICHT enthalten: + * - `user` — das ist die Bürger-Spur, kein Rollenträger-Abschnitt. + * - `super_admin` — Betreiberrolle, keine öffentliche Anleitung (Konzept c6). + * - `ortsteil_admin` / `kreis_admin` / `land_admin` — Reserve, nicht in Betrieb. + * Unbekannte Rollentypen laufen ins Leere (fail-quiet): der Hinweis entfällt, + * die drei Karten bleiben. + */ +export const ROLLE_ZU_ABSCHNITT: Record = { + verifier: { spurId: "verifizierung", bezeichnung: "Verifizierung" }, + redakteur: { spurId: "redaktion", bezeichnung: "Redaktion" }, + kommune_admin: { spurId: "administration", bezeichnung: "Verwaltung" }, + beobachter: { spurId: "beobachtung", bezeichnung: "Beobachtung" }, +}; + +/** + * Welche Abschnitte sind für diese Rollen einschlägig? REINE Funktion (ohne DB), + * stabile Reihenfolge = Reihenfolge von AUFGABEN_SPUREN. Doppelte Rollen und + * unbekannte Rollentypen fallen heraus. + */ +export function abschnitteFuerRollen( + roleTypes: string[], +): { spurId: string; bezeichnung: string; titel: string }[] { + const treffer = new Map(); + for (const rt of roleTypes) { + const eintrag = ROLLE_ZU_ABSCHNITT[rt]; + if (!eintrag) continue; + const spur = AUFGABEN_SPUREN.find((s) => s.id === eintrag.spurId); + if (!spur) continue; + treffer.set(eintrag.spurId, { ...eintrag, titel: spur.titel }); + } + return AUFGABEN_SPUREN.flatMap((s) => { + const t = treffer.get(s.id); + return t ? [t] : []; + }); +} diff --git a/app/src/app/[tenant]/anleitung/aufgaben/page.tsx b/app/src/app/[tenant]/anleitung/aufgaben/page.tsx new file mode 100644 index 0000000..2e27c39 --- /dev/null +++ b/app/src/app/[tenant]/anleitung/aufgaben/page.tsx @@ -0,0 +1,106 @@ +/** + * [tenant]/anleitung/aufgaben/page.tsx — die Rollenträger-Anleitung. + * + * EINE Seite mit vier Abschnitten (Verifizierung, Redaktion, Administration, + * Beobachtung) statt vier Routen — bewusst: Rollen kommen kombiniert vor + * (ein kommune_admin darf auch verifizieren), und eine lange Seite lässt sich + * am Stück lesen, durchsuchen und ausdrucken. Jeder Abschnitt hat einen stabilen + * Anker, damit die Abholseite direkt hineinspringen kann. + * + * ÖFFENTLICH LESBAR (Owner-Entscheidung): Die Schutzwirkung sitzt an den echten + * Flächen (Server-Guards), nicht an der Dokumentation. Wer nachlesen kann, was + * eine Verifizierungsstelle darf und was sie NICHT speichert, kann der Plattform + * eher vertrauen. Kein DB-, kein Session-Zugriff auf dieser Seite. + * + * KEINE Spur für die Betreiberrolle (super_admin) — Betreiber-Wissen gehört in + * die internen Runbooks, nicht in eine öffentliche Anleitung. + * + * Inhalte ändern: NUR in ../anleitung-daten.ts (AUFGABEN_SPUREN). + */ + +import type { Metadata } from "next"; +import Link from "next/link"; +import SpurInhalt from "../SpurInhalt"; +import { AUFGABEN_SPUREN } from "../anleitung-daten"; + +export const metadata: Metadata = { + title: "Anleitung für Rollenträger — Partizip", + description: + "Wohnsitz bestätigen, Ratsinfos schreiben, verwalten und freigeben, mitlesen — " + + "was jede Aufgabe umfasst und wo ihre Grenzen liegen.", +}; + +export default async function AnleitungAufgabenPage({ + params, +}: { + params: Promise<{ tenant: string }>; +}) { + const { tenant: slug } = await params; + + return ( +
+

+ + Alle Anleitungen + +

+ +
+

+ Anleitung für Rollenträger +

+

+ Vier Aufgaben, vier Abschnitte. Wer mehrere Rollen hat, liest mehrere + Abschnitte — die Rechte addieren sich, die Grenzen bleiben. +

+
+ + {/* Inhaltsverzeichnis: erlaubt das Springen und macht den Umfang sichtbar. + Als
+ ); +} diff --git a/app/src/app/[tenant]/anleitung/mitmachen/page.tsx b/app/src/app/[tenant]/anleitung/mitmachen/page.tsx new file mode 100644 index 0000000..a8110d6 --- /dev/null +++ b/app/src/app/[tenant]/anleitung/mitmachen/page.tsx @@ -0,0 +1,52 @@ +/** + * [tenant]/anleitung/mitmachen/page.tsx — die Bürger-Spur. + * + * ÖFFENTLICH (Stufe 0), ohne Datenbank- und ohne Session-Zugriff: die Seite ist + * reine Textausspielung aus anleitung-daten.ts. Damit funktioniert sie auch + * ohne JavaScript vollständig und lässt sich sinnvoll ausdrucken. + * + * Inhalte ändern: NUR in ../anleitung-daten.ts (BUERGER_SPUR). + */ + +import type { Metadata } from "next"; +import Link from "next/link"; +import SpurInhalt from "../SpurInhalt"; +import { BUERGER_SPUR } from "../anleitung-daten"; + +export const metadata: Metadata = { + title: "Anleitung: Mitmachen — Partizip", + description: + "Schritt für Schritt: lesen, mitstimmen, Wohnsitz bestätigen lassen. Und was " + + "mit Ihrer Stimme passiert.", +}; + +export default async function AnleitungMitmachenPage({ + params, +}: { + params: Promise<{ tenant: string }>; +}) { + const { tenant: slug } = await params; + + return ( +
+ {/* Rücksprung zur Abholseite — im Ausdruck überflüssig, deshalb dort aus. */} +

+ + Alle Anleitungen + +

+ +
+

+ {BUERGER_SPUR.titel} +

+
+ + +
+ ); +} diff --git a/app/src/app/[tenant]/anleitung/page.tsx b/app/src/app/[tenant]/anleitung/page.tsx new file mode 100644 index 0000000..828bf6c --- /dev/null +++ b/app/src/app/[tenant]/anleitung/page.tsx @@ -0,0 +1,214 @@ +/** + * [tenant]/anleitung/page.tsx — die Abholseite. + * + * Drei Karten, die nach der SITUATION fragen, nicht nach dem Rollennamen: + * Bürger denken nicht in Rollen („bin ich ein ‚user'?"), und `redakteur` oder + * `beobachter` sind interne Begriffe. Wer eine Aufgabe hat, weiß das dagegen. + * + * PERSÖNLICHER HINWEIS BEI ANGEMELDETEN: Trägt die angemeldete Person eine + * Rolle, steht über den Karten ein direkter Sprung in ihren Abschnitt. Die + * Rollen werden über getUserRoleTypes geladen — denselben Weg wie /admin und + * /aufgaben, der gesperrte/gelöschte Konten ausfiltert (innerer JOIN auf + * account_status='active'). Der Hinweis ist reiner KOMFORT: Er schaltet nichts + * frei, die Anleitung selbst ist ohnehin öffentlich lesbar. + * + * AUSGELOGGT ⇒ KEIN DB-ZUGRIFF: ohne Session-Cookie wird gar keine Verbindung + * aufgebaut, die Seite ist dann reine Textausspielung. + * + * KEINE Weiterleitungen: Die Abholseite ist für jede Besucherin erreichbar — + * anders als /aufgaben, das Nicht-Rollenträger wegleitet. + */ + +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { headers, cookies } from "next/headers"; +import { and, eq } from "drizzle-orm"; +import Link from "next/link"; +import { createDb } from "@/db/client"; +import { getTenantFromHost } from "@/lib/tenant"; +import { sessions } from "@/db/schema"; +import { sha256Hex } from "@/lib/auth/crypto"; +import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { getUserRoleTypes } from "@/lib/auth/roles"; +import { isDemoTenant } from "@/lib/demo/config"; +import { EINSTIEG_KARTEN, abschnitteFuerRollen } from "./anleitung-daten"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Anleitung — Partizip", + description: + "Die Anleitung zu Partizip: fürs Mitmachen, für Rollen in der Kommune und " + + "für die Vorstellung der Plattform.", +}; + +function databaseUrl(): string { + return ( + process.env.DATABASE_URL ?? "postgres://partizip:partizip@127.0.0.1:5433/partizip" + ); +} + +/** + * Rollen der angemeldeten Person — oder [] (nicht angemeldet, Sitzung abgelaufen + * oder widerrufen). Fehlertolerant gedacht: Diese Seite darf am Rollen-Hinweis + * nicht scheitern, sie ist in erster Linie eine öffentliche Textseite. + */ +async function rollenDerSitzung(tenantId: string): Promise { + const cookieStore = await cookies(); + const rawToken = cookieStore.get(SESSION_COOKIE_NAME)?.value; + if (!rawToken) return []; + + const db = createDb(databaseUrl()); + const sessionRows = await db + .select() + .from(sessions) + .where(and(eq(sessions.tokenHash, sha256Hex(rawToken)), eq(sessions.tenantId, tenantId))) + .limit(1); + + const session = sessionRows[0]; + if (!session || session.revokedAt || session.expiresAt < new Date()) return []; + + return getUserRoleTypes(db, tenantId, session.userId); +} + +export default async function AnleitungPage({ + params, +}: { + params: Promise<{ tenant: string }>; +}) { + const { tenant: slug } = await params; + + const headerStore = await headers(); + const host = headerStore.get("host") ?? "localhost"; + const tenant = await getTenantFromHost(host); + if (!tenant || tenant.slug !== slug) notFound(); + + // Demo-Mandant: dort führt der eigene Rundgang, und die Rollen sind ephemer + // (Wegwerf-Admin des Demo-Resets). Ein „Sie sind als … eingetragen" wäre dort + // irreführend — die drei Karten genügen. + const roleTypes = isDemoTenant(tenant.slug) ? [] : await rollenDerSitzung(tenant.id); + const meineAbschnitte = abschnitteFuerRollen(roleTypes); + + return ( +
+
+

+ Anleitung +

+

+ Wählen Sie, was auf Sie zutrifft. Jede Anleitung erklärt Bedeutung und + Reihenfolge und verlinkt dann an die passende Stelle — sie ersetzt keinen + Kurs und braucht kein Vorwissen. +

+
+ + {/* Vorwegnahme für Rollenträger: direkter Sprung in den eigenen Abschnitt. + Ohne Rolle (oder ausgeloggt) entfällt der Block ersatzlos. */} + {meineAbschnitte.length > 0 && ( +
+

+ Für Sie hinterlegt +

+

+ Sie sind bei {tenant.name} für{" "} + {meineAbschnitte.length === 1 ? "diese Aufgabe" : "diese Aufgaben"}{" "} + eingetragen: +

+
    + {meineAbschnitte.map((a) => ( +
  • + + {a.titel} + +
  • + ))} +
+
+ )} + +

+ Was trifft auf Sie zu? +

+
    + {EINSTIEG_KARTEN.map((karte) => ( +
  • + +
  • + ))} +
+ +

+ Sie suchen nur eine schnelle Antwort? Die{" "} + + häufigen Fragen + {" "} + fassen die wichtigsten Punkte in je zwei Sätzen zusammen. +

+
+ ); +} + +/** + * Eine Einstiegs-Karte. Die ganze Karte ist EIN Link (eine Aktion je Karte, + * UX-Leitbild); der Titel bleibt trotzdem eine echte Überschrift, damit die + * Sprungmarken-Liste eines Screenreaders die drei Wege abbildet. + */ +function KarteInhalt({ + karte, + slug, +}: { + karte: (typeof EINSTIEG_KARTEN)[number]; + slug: string; +}) { + const href = karte.link.absolut ? karte.link.href : `/${slug}${karte.link.href}`; + const klassen = + "pz-card pz-card-hover group flex h-full flex-col p-6 " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pz-brand)]"; + const inhalt = ( + <> +

+ {karte.titel} +

+

+ {karte.text} +

+

+ {karte.link.label} +

+ + ); + + // Das statische Präsentations-Deck liegt außerhalb des Next-Routings + // (middleware.ts nimmt /praesentation vom Tenant-Rewrite aus) — dorthin führt + // ein normales , kein next/link. + if (karte.link.absolut) { + return ( + + {inhalt} + + ); + } + return ( + + {inhalt} + + ); +} diff --git a/app/src/app/[tenant]/aufgaben/page.tsx b/app/src/app/[tenant]/aufgaben/page.tsx index bc98c5d..d170792 100644 --- a/app/src/app/[tenant]/aufgaben/page.tsx +++ b/app/src/app/[tenant]/aufgaben/page.tsx @@ -89,6 +89,18 @@ export default async function AufgabenPage({ params }: PageProps) { {tenant.name} — wählen Sie eine Funktion. Über „Ansicht“ oben wechseln Sie jederzeit zurück zur Bürger-Ansicht.

+ {/* Anleitung als Text-Link statt als weitere Kachel: die Kacheln spiegeln + exakt die Server-Guards (lib/aufgaben/kacheln.ts) — eine Kachel ohne + Rechte-Bezug würde diese Invariante aufweichen. */} +

+ + Anleitung: was jede Aufgabe umfasst + +

diff --git a/app/src/app/[tenant]/faq/page.tsx b/app/src/app/[tenant]/faq/page.tsx index c1190d5..d983db0 100644 --- a/app/src/app/[tenant]/faq/page.tsx +++ b/app/src/app/[tenant]/faq/page.tsx @@ -34,6 +34,19 @@ export default async function FaqPage({ Kurz beantwortet — ohne Fachsprache. Ihre Frage fehlt? Schreiben Sie uns über die Angaben im Impressum.

+ {/* Diese Seite ist die Kurzform. Wer den ganzen Ablauf sucht (oder eine + Rolle in der Kommune hat), ist in der Anleitung richtig — verlinkt + statt dupliziert, damit es EINE Quelle je Aussage bleibt. */} +

+ + Zur ausführlichen Anleitung + {" "} + — Schritt für Schritt fürs Mitmachen und für Aufgaben in der Kommune. +

diff --git a/app/src/app/[tenant]/layout.tsx b/app/src/app/[tenant]/layout.tsx index 5620c4e..8d392d3 100644 --- a/app/src/app/[tenant]/layout.tsx +++ b/app/src/app/[tenant]/layout.tsx @@ -306,6 +306,17 @@ function TenantLayoutInner({ Transparenz · + {/* Die Anleitung steht bewusst VOR der FAQ: sie ist der ausführliche + Einstieg („was mache ich hier?"), die FAQ die Kurzform. Beide sind + dauerhaft nur über den Footer erreichbar — die Landing verschwindet + mit dem Region-Cookie. */} + + Anleitung + + · summary { + list-style: none; + } + details::details-content { + content-visibility: visible; + block-size: auto; + } + details:not([open]) > *:not(summary) { + display: block; + } + + /* Überschriften nicht allein am Seitenfuß stehen lassen. */ + h1, + h2, + h3 { + break-after: avoid; + } +}