15 Commits

Author SHA1 Message Date
schalli c001a081c2 docs(quick-260921-bi2): Lint-Rueckstand 2923 -> 465, Akte und Verifikation
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m24s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m15s
Zusammenfassung, Verifikation und STATE.md zum Quick-Vorgang 260921-bi2.

Kernbefund: Biomes als "safe" eingestufte Korrektur style/useImportType
zerstoert in apps/api die NestJS-Abhaengigkeitsspritze — das erzeugte
__metadata("design:paramtypes", [...]) kollabiert zu [Function, ...] und
die API startet nicht mehr, waehrend tsc gruen bleibt und alle 1124
API-Tests gruen bleiben (kein Test ruft createTestingModule auf).
Deshalb ein zweiter, auf apps/api/** begrenzter overrides-Eintrag.

Nachweis fuer "kein Verhaltenswechsel" ist nicht die Testsuite, sondern
ein sha256 ueber alle 593 erzeugten __metadata-Zeilen (6e1583f1...),
vor und nach dem Umbau identisch. Dazu Klicktest am laufenden System
mit den echten Abbildern: API healthy, Abbrechen legt nichts an,
Speichern legt an, ADMIN sieht auf der SUPER_ADMIN-Zeile nur Details,
abgewiesene Server-Antworten erscheinen sichtbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 10:02:44 +02:00
schalli 97a6836444 docs(quick-260921-bi2): Entwickleranleitung auf Endstand 465/386/79 gebracht
Der Lint-Abschnitt trug noch den Zwischenstand aus Aufgabe 1 (754/633/121).
Jetzt der tatsaechliche Endstand nach allen drei Aufgaben, beide Ausnahmen
weiterhin begruendet, plus die namentlich benannten Folgeaufgaben: fuenf
zurueckgestellte a11y-Regeln, die bewusst nicht angewendete
noUselessSwitchCase-Fundstelle, und die vier gemeldeten D-03-Symptomfunde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:43:40 +02:00
schalli c79bafa179 fix(quick-260921-bi2): a11y - Beschriftungen an Felder binden (22 Fundstellen)
noLabelWithoutControl (22) auf 0: jede Beschriftung ueber htmlFor/id an ihr
Feld gebunden, in Formularen mit wiederholten Zeilen ueber praefixierte,
seitenweit eindeutige Kennungen (z.B. ldap-*, user-*, tenant-*).

Sonderfall calendar-source-form.tsx: die Farbauswahl beschriftet eine ganze
Gruppe von Farb-Schaltflaechen, kein einzelnes Feld. Dafuer fieldset/legend
statt htmlFor/id (Rand/Abstand zurueckgesetzt, damit sich am Erscheinungsbild
nichts aendert) -- eine Umwandlung in <span> haette die Assoziation entfernt
statt sie herzustellen, darum nicht gewaehlt.

Damit steht der gesamte Lint-Rueckstand bei 465 (386 echt, 79 Test),
Fehlerstufe 0 -- Zielwert dieses Vorgangs erreicht. Die fuenf zurueck-
gestellten Regeln (noNoninteractiveElementInteractions, useKeyWithClick-
Events, noStaticElementInteractions, useAriaPropsSupportedByRole,
noAutofocus) stehen unveraendert bei 11/5/5/5/4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:38:14 +02:00
schalli 21c85a8b88 fix(quick-260921-bi2): a11y - Rollen, Semantik, Tab-Reihenfolge (32er-Rest, Teil 1/2)
Vier der fuenf noch offenen Regeln aus Teillieferung B bereinigt:

- noRedundantRoles (4): ueberfluessige role-Angaben auf button/time/select
  entfernt (maschineller --unsafe-Fix, gelesen).
- useAriaPropsForRole (1): entfaellt automatisch mit obigem Fix -- das
  <select role="combobox"> in search-widget.tsx verlangte die fehlenden
  ARIA-Attribute nur wegen der ueberfluessigen Rolle.
- useSemanticElements (4): admin-sidebar/settings-sidebar tragen role=
  "navigation" jetzt am <nav> statt am <aside> (kein doppeltes Landmark
  mehr); widget-wrapper.tsx ist jetzt ein echtes <article> statt
  div role="article"; DropZone.tsx trennt die Datei-Entfernen-Schaltflaeche
  als Geschwister ab, damit die Drop-Flaeche selbst ein echtes <button>
  werden kann (ein <button> darf kein zweites <button> verschachteln).
  Die Drop-Flaeche traegt darum jetzt Klick- UND Drag-Handler direkt am
  <button>, sonst waere sie ein "statisches" Element mit Ereignis-Handlern
  geworden (die zurueckgestellten Regeln noStaticElementInteractions /
  noNoninteractiveElementInteractions waeren neu angeschlagen -- geprueft,
  bleiben bei 5/11).
- noNoninteractiveTabindex (1): calculator-widget.tsx traegt jetzt
  tabIndex={-1} statt {0}. Die Zifferntasten sind bereits echte <button>
  und damit selbst Teil der Tab-Reihenfolge; Tastendruecke erreichen
  handleKeyboard weiterhin per Bubbling, sobald eine Taste fokussiert ist.
  Verhalten unveraendert, nur ein wirkungsloser Tab-Stopp auf dem Container
  selbst entfaellt.

Verbleibend: noLabelWithoutControl (22), naechster Schritt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:33:56 +02:00
schalli 73ac08af17 fix(quick-260921-bi2): a11y - Bildmarke und Desktop-Einrichtungsseite
apps/web/src/app/icon.svg (Next.js-Favicon, keine next-intl-Anbindung
moeglich): <title>Tessera</title> als einzige Fundstelle, die die ganze
Bedeutung allein traegt.

apps/desktop/src/setup.html: Bildmarke steht direkt vor der Ueberschrift
"Tessera" und wird dekorativ (aria-hidden="true").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:49 +02:00
schalli 27a6e2952c fix(quick-260921-bi2): a11y - Layout-Navigation und Einstellungsformulare
useButtonType: Hamburger-, Profil-, Kategorie-, Ein-/Ausklapp- und Logout-
Buttons (kein Formular in diesen Dateien) erhalten type="button".

noSvgWithoutTitle: Icons neben sichtbarem Text werden dekorativ
(aria-hidden="true"). Zwei bislang unbenannte interaktive Elemente erhalten
zusaetzlich ein aria-label, weil ihr Text im eingeklappten Sidebar-Zustand
verschwindet bzw. ganz fehlte: die Dashboard-/Marketplace-Links und der
"Einstellungen"-Button in der Seitenleiste (t('dashboard')/t('marketplace')/
t('settings'), alle bereits vorhandene Schluessel) sowie der mobile
Schliessen-Button (tCommon('close')).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:43 +02:00
schalli 969fd01f5c fix(quick-260921-bi2): a11y - Dashboard-Widgets und Portal-Startseite
useButtonType: "Widget hinzufuegen"-, Bearbeiten-Modus- und Katalog-Buttons
(kein Formular in diesen Dateien) erhalten type="button".

noSvgWithoutTitle: Widget-Icons in der Katalog-Kachel (immer neben dem
Widget-Namen) und Buttons mit bestehendem aria-label/title werden dekorativ
(aria-hidden="true"); der bislang unbeschriftete Such-Button im Such-Widget
erhaelt aria-label={t('search.searchButton')} (neuer Schluessel, siehe
vorherige i18n-Festschreibung).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:34 +02:00
schalli 278aedb201 fix(quick-260921-bi2): a11y - Modul-Bereich (Kategorie-Seiten, Cert-Manager)
useButtonType: alle Aktions-Buttons in Cert-Manager-Tabs (kein Formular in
diesen Dateien) erhalten type="button".

noSvgWithoutTitle: Modul-Icons neben dem Modulnamen, Leer- und Nicht-
gefunden-Zustaende neben ihrer Ueberschrift werden dekorativ
(aria-hidden="true"); die Augen-Icons im Passwortfeld sind bereits ueber das
aria-label des umschliessenden Buttons benannt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:24 +02:00
schalli ae821254ef fix(quick-260921-bi2): a11y - Marketplace-Bereich
useButtonType: alle Filter-, Karten- und Dialog-Schaltflaechen (keine davon
in einem Formular) erhalten type="button".

noSvgWithoutTitle: Modul-Icons neben dem Modulnamen und die Erfolgs-/Fehler-
Symbole neben der Toast-Nachricht werden dekorativ (aria-hidden="true"); der
bislang unbeschriftete Toast-Schliessen-Button erhaelt aria-label={t('close')}
aus dem bereits vorhandenen common.close-Schluessel, sein Icon wird dekorativ.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:17 +02:00
schalli 4cff3167ed fix(quick-260921-bi2): a11y - Administrationsbereich (Gruppen, LDAP, Module, Mandanten, Benutzer)
useButtonType: alle Schaltflaechen ausserhalb der drei echten Formulare
(Mandanten/Benutzer/LDAP) erhalten type="button"; die drei tatsaechlichen
Absende-Buttons behalten type="submit" (Zahl bleibt 1/1/2, siehe <verify>).

noSvgWithoutTitle: Symbole neben sichtbarem Text (Zurueck-Pfeil, Navigations-
Icons in der Admin-Seitenleiste) werden dekorativ (aria-hidden="true"); das
Schloss-Symbol der LDAP-Standardzuordnung traegt jetzt einen eigenen Titel
(admin.ldap.fieldMapping.defaultIcon), weil es ohne begleitenden Text pro
Tabellenzeile die ganze Bedeutung allein traegt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:21:09 +02:00
schalli e76f3b8d84 fix(quick-260921-bi2): a11y - Login, Passwort-Reset und Passwort-Aenderung
useButtonType (Absenden-Button behaelt type="submit", keine weiteren Buttons
in diesen Dateien) und noSvgWithoutTitle (Lade-Spinner im Absenden-Button ist
rein dekorativ, da er den sichtbaren Beschriftungstext waehrend des Ladens
ersetzt -> aria-hidden="true").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:20:59 +02:00
schalli 38115783b4 i18n(quick-260921-bi2): neue Schluessel fuer a11y-Beschriftungen (LDAP-Standardzuordnung, Suchbutton)
- admin.ldap.fieldMapping.defaultIcon: Beschriftung fuer das Schloss-Symbol der
  Standardzuordnung (Aufgabe 3, noSvgWithoutTitle)
- widgets.search.searchButton: Beschriftung fuer den bislang unbenannten
  Such-Icon-Button (Aufgabe 3, noSvgWithoutTitle)
- de/en Schluesselmengen bleiben identisch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 09:20:53 +02:00
schalli 636fe0df8f refactor(quick-260921-bi2): maschinelle Lint-Fixe und toten Code abbauen
- Aufgabe 2: vier sichere Biome-Regeln (useImportType pfadgebunden auf
  apps/web+packages, noUselessEscapeInRegex, useConst,
  useExponentiationOperator) sowie fuenf ungesicherte Regeln
  (useNodejsImportProtocol, useLiteralKeys, useOptionalChain, useTemplate,
  useParseIntRadix) angewendet und den gesamten Diff von Hand gelesen
  (ldap.service.ts zeichenweise gegen Gross-/Kleinschreibung der
  AD-Merkmale, auth.service.ts/jwt.strategy.ts gegen Durchwinken bei
  fehlender Sitzung geprueft)
- noUselessSwitchCase bleibt bewusst stehen (tender-normalizer.service.ts:60,
  die Fallmarke dokumentiert Absicht)
- Toter Code (D-03): fuenf folgenlose Auffangvariablen entfernt, eine
  nicht benutzte Funktion (forSystemQuery, Pruefskript) entfernt, ein
  positionsgebundener Dekoratorparameter umbenannt (current-user.decorator.ts),
  fuenf Symptomfunde entfernt und als Folgeaufgaben zu melden (siehe unten)
- Sechs weitere, im Plan nicht namentlich gelistete aber
  gleich-kategorische Dead-Code-Fundstellen in Testdateien zusaetzlich
  bereinigt (groups.service.spec.ts, cert-manager.test.tsx,
  ldap.service.spec.ts, prisma-tenant.extension.spec.ts x3) — noetig, um
  die vom Plan selbst verlangten Nullstaende bei noUnusedVariables/
  noUnusedImports/noUnusedFunctionParameters zu erreichen

Dekoratordaten aus apps/api unveraendert (593 Zeilen, sha256 6e1583f1...).
Endstand 620 Befunde (541 echt, 79 Test) statt der im Plan geschaetzten
621/542 — eine Differenz von 1, weil das Streichen des Namens aus
`catch (e: any)` in calendar.service.ts (Symptom-Fix) den dort ebenfalls
gemeldeten noExplicitAny-Befund miteliminiert; das ist eine erwuenschte
Nebenwirkung, keine Regression. Fehlerstufe 0, beide Testlaeufe
punktgleich gruen (69/1124, 66/459), pnpm type-check 4/4, pnpm lint
--force 5/5.

Folgeaufgaben aus D-03 (nicht in diesem Vorgang behoben):
- force-password-change.interceptor.ts: Freigabeliste prueft nur den Pfad,
  nicht die HTTP-Methode
- change-password/page.tsx: nach erzwungenem Wechsel bleibt die Person auf
  der Seite stehen (keine Weiterleitung, keine Aktualisierung der
  Benutzerablage)
- VehicleTable.tsx: Loeschschaltflaeche hat keinen Besetztzustand, laesst
  sich doppelt ausloesen
- SplitTab.tsx: downloadAllAsZip erhielt eine ungenutzte
  Uebersetzungsfunktion, Hinweis auf fest verdrahtete Texte im Zip-Pfad

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:58:32 +02:00
schalli 8d1c8f320b chore(quick-260921-bi2): Lint-Konfiguration bereinigen, Messgrundlage herstellen
- overrides fuer Testdateien (noExplicitAny off) und apps/api (useImportType off, siehe
  Regelbeschreibung "Caveat with TypeScript experimental decorators")
- Fixture-Ausnahme in files.includes ohne angehaengten Doppelstern (Biome-Hinweis
  useBiomeIgnoreFolder befolgt)
- Entwickleranleitung auf den neuen Stand gebracht: 754 Befunde (633 echt, 121 Test), beide
  Ausnahmen begruendet, Folgeaufgaben benannt

Rueckstand: 2923 -> 754, Fehlerstufe weiterhin 0. security-Regelgruppe unangetastet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:46:06 +02:00
schalli e7c2c4c6a9 docs(quick-260921-bi2): Plan fuer den Lint-Rueckstand (2923 -> 466)
Drei Durchgaenge nach Risikoklasse: Konfiguration, maschinelle Fixes
plus toter Code, Barrierefreiheit von Hand. Alle Zielzahlen sind beim
Planen probeweise ausgefuehrt, gemessen und zurueckgenommen worden.

Wichtigster Befund: style/useImportType darf in apps/api nicht
maschinell laufen. emitDecoratorMetadata plus NestJS bedeutet, dass die
Korrektur 61 von 65 Dateien mit Abhaengigkeitsdaten zerstoert — bei
gruenem tsc und gruenen 1124 Tests, weil kein Test den Container
startet. Der Plan begrenzt die Regel auf apps/web und verankert als
Nachweis einen sha256 ueber die erzeugten __metadata-Zeilen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 08:39:32 +02:00
114 changed files with 1671 additions and 265 deletions
+6 -5
View File
@@ -4,10 +4,10 @@ milestone: v1.2
current_phase: 18
current_phase_name: desktop-client-fertigstellen
status: verified
stopped_at: "WINDOWS #35 und #36 abgeschlossen und verifiziert (7 Commits, alle Gatter gruen); nicht gepusht — Push und CI-Lauf stehen noch aus"
last_updated: "2026-09-21T05:45:00.000Z"
stopped_at: "WINDOWS #35, #36 und der Lint-Rueckstand abgeschlossen und verifiziert (inkl. Klicktest am laufenden System); Push und CI-Lauf fuer das Image stehen an. CI-Lauf 388 war rot durch einen Absturz des Gitea-Runners (act_runner v0.6.1, panic: close of closed channel) — Lint/Type-Check/Tests waren darin gruen, nur der Desktop-Job starb, dadurch wurde Build & Publish Images uebersprungen."
last_updated: "2026-09-21T08:10:00.000Z"
last_activity: 2026-09-21
last_activity_desc: Quick 260921-9ie und 260921-a1d — WINDOWS #35 (Biome lauffaehig, Lint-Tor scharf) und #36 (Benutzerverwaltung meldet 403 sichtbar, keine Aktionsknoepfe auf SUPER_ADMIN-Zeilen fuer ADMIN) geschlossen und verifiziert
last_activity_desc: Quick 260921-9ie, 260921-a1d und 260921-bi2 — Biome lauffaehig und Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen sichtbar, Lint-Rueckstand 2923 → 465; alle drei verifiziert, der letzte zusaetzlich per Klicktest am laufenden System
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress:
total_phases: 18
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-21 - Quick 260921-a1d (WINDOWS #36): Benutzerverwaltung meldet abgewiesene Server-Antworten sichtbar (drei Stellen), ADMIN bekommt in der SUPER_ADMIN-Zeile keine Aktionsknoepfe mehr; apps/api unangetastet, Web-Tests 66/459 gruen
Last activity: 2026-09-21 - Quick 260921-bi2: Lint-Rueckstand 2923 → 465 Warnungen abgebaut (Konfiguration, maschinelle Fixes, toter Code, 155 Handkorrekturen Barrierefreiheit); Beinahe-Schaden abgewendet — Biomes als sicher eingestufte useImportType-Korrektur haette die NestJS-Abhaengigkeitsspritze zerstoert, unbemerkt von tsc und 1124 gruenen Tests
Progress: [██████████] 99%
@@ -446,6 +446,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| fast | **Desktop-Client: Setup-Seite zeigt Version und Stand der App** („Tessera-App 1.2.0 · Stand a6d1a64"; ohne Stempel nur Version) — Command `get_client_info`, Helfer `client_info_label` (2 Tests), `<p id="client-info">` in setup.html, CHANGELOG. Diente zugleich als zweiter Desktop-Stand fuer den Update-Nachweis. 35 Rust-Tests. | 2026-09-18 | a6d1a64 | — |
| 260921-9ie | **Biome lauffaehig machen und das Lint-Tor scharf schalten (WINDOWS #35).** `biome.json` per `biome migrate` auf Biome 2.5.0 gezogen: `organizeImports` nach `assist.actions.source`, `linter.rules.recommended` → `preset: "recommended"`, `javascript.parser.unsafeParameterDecoratorsEnabled` (NestJS-Parameter-Dekoratoren: 238 parse-Fehler in 19 Dateien → 0), `quoteStyle: single` (belegt: 1496 einfach-gequotete Importzeilen gegen null doppelte), `vcs.useIgnoreFile`, Ausschluss von `**/__fixtures__/**` (nur html/zip/xml, keine TS-Datei) und `globals.css` (Tailwind-4-At-Regeln). `lint`-Skript (`biome lint .`) in allen fuenf Workspaces; `turbo.json` bekommt `globalDependencies: ["biome.json"]`, sonst liefert der Cache nach einer Regelaenderung alte Ergebnisse. **Zweig (b) gewaehlt, gemessen:** `biome check .` → Exit 1/760 Fehler, `biome lint .` → Exit 1/275, also kein "nur Warnungen"-Ausweg; Skript ruft `lint` statt `check` (haelt 319 Formatierungsbefunde draussen, kein Rundumumbau), Rest gezielt auf `warn` → 0 Fehler, Exit 0. **Sicherheit:** Gruppe `security` bleibt auf `error`, maschinell geprueft; die 6 `noScriptUrl`-Treffer lagen ausnahmslos in der ausgeschlossenen HTML-Testvorlage, keiner in echtem Quellcode. **Registereintrag #35 war in zwei Punkten falsch:** Pfad ist `apps/api/src/user/...` (Einzahl), und die Wirkung des Parser-Schalters betrug 238 statt 17 Fehler. **Nachweise (dreifach unabhaengig — Planer, Orchestrator, Verifier):** `pnpm lint` → "5 successful, 5 total", Exit 0 (vorher "No tasks were executed"); Gegenprobe mit Wegwerfdatei (`debugger`) → Exit 1 mit `noDebugger`, danach Baum wieder sauber; repo-weit 0 parse-Fehler, 0 Fehler; Diff nur Konfiguration/Skripte/Doku, keine Quelldatei, `pnpm-lock.yaml` unveraendert. Verifikation passed (7/7). **Offen als eigener Durchlauf:** rund 2800 Warnungen (`any`-Familie, Barrierefreiheit in `apps/web`), in `docs/anleitung-entwicklung.md` als bewusster Rueckstand festgehalten. | 2026-09-21 | 6a727e9,00d769b,6f0f05a | [260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r](./quick/260921-9ie-windows-35-biome-json-fuer-biome-2-5-0-r/) |
| 260921-a1d | **Benutzerverwaltung: verbotene Aktionen melden sich jetzt (WINDOWS #36).** Drei Stellen in `apps/web/src/app/(portal)/admin/users/page.tsx` verschluckten Server-Antworten still (`if (res.ok)` ohne else, `catch {}` mit dem Kommentar `// silently fail`): Liste laden, Formular speichern, Loeschen. Sichtbare Wirkung vorher: Formular blieb offen, Loeschdialog stand still, beim gescheiterten Laden log die Seite mit "Keine Benutzer gefunden". Jetzt je ein Banner (`role="alert"`) im Listenkopf, im Formulardialog und im Loeschdialog; `readApiMessage(res)` liest ausschliesslich `body.message` und zeigt den Servertext in einem deutschen Rahmensatz, sonst eine uebersetzte Ersatzmeldung — auch wenn der `fetch` selbst wirft. Vier Schluessel `admin.users.errors.*` in `de.json` **und** `en.json` (Katalog-Paritaet 890/890 geprueft). Dazu `canManageRow`: einem ADMIN werden Bearbeiten/Loeschen in der SUPER_ADMIN-Zeile gar nicht erst angeboten (seit #29 im Alltag erreichbar), "Details" bleibt ueberall. **`apps/api` blieb unangetastet** — der Zielrollen-Riegel im Controller ist und bleibt die wirksame Grenze, der versteckte Knopf ist Ergonomie darueber, kein Ersatz; eigenes Gatter im Plan weist das nach. Gemessen: keine Namen/IDs/Stapelspuren in den 403-Rumpftexten (kein eigener ExceptionFilter in `apps/api/src`). **Nachweise (dreifach unabhaengig):** Web-Tests 66 Dateien/459 Tests gruen (vorher 65/447, neue `users-page.test.tsx` prueft echten DOM-Text via `getByText`/`within`, nicht nur State-Setter), type-check Exit 0, `pnpm lint` 5/5 ohne neue Fehlerrang-Meldung, `silently fail` im Code 3 → 0, `role="alert"` 0 → 3, Diff nur vier Dateien unter `apps/web`. Verifikation passed (7/7). | 2026-09-21 | 38d2586,51bff75,13b70df | [260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-](./quick/260921-a1d-windows-36-benutzerverwaltung-zeigt-bei-/) |
| 260921-bi2 | **Lint-Rueckstand abgebaut: 2923 → 465 Warnungen (WINDOWS #35 Folgearbeit).** Seit das Lint-Tor wirklich prueft, war der Rueckstand sichtbar. Aufgeteilt nach Risiko statt nach Datei: (1) Konfiguration — zwei begruendete `overrides`, (2) maschinelle Fixes + toter Code, (3) Barrierefreiheit von Hand. **Der wichtigste Befund ist ein Beinahe-Schaden:** Biomes `style/useImportType`-Korrektur ist als *safe* eingestuft, zerstoert in `apps/api` aber die NestJS-Abhaengigkeitsspritze — `__metadata("design:paramtypes", [PrismaService, …])` kollabiert zu `[Function, …]`, 61 von 65 Dateien betroffen, API startet nicht mehr. Dabei bleibt `tsc` gruen **und alle 1124 API-Tests bleiben gruen**, weil kein einziger Test `createTestingModule` aufruft — das waere durch jedes vorhandene Tor unbemerkt bis auf alpha durchgelaufen. Planer und Plan-Pruefer haben es unabhaengig voneinander reproduziert (Datei kompiliert, Metadatenzeile verglichen). Deshalb zweiter `overrides`-Eintrag auf `apps/api/**`. Zweite Falle, ebenfalls gemessen: `--only=<regel>` schaltet eine in der Konfiguration abgeschaltete Regel wieder AN — ein repo-weites `biome lint . --only=useImportType --write` haengt die Ausnahme aus (77 API-Dateien veraendert). Nur pfadgebundene Aufrufe. **Nachweis, dass sich nichts geaendert hat, ist NICHT die Testsuite**, sondern ein sha256 ueber alle 593 erzeugten `__metadata`-Zeilen: `6e1583f1…`, vor und nach dem Umbau identisch, dreifach geprueft. Barrierefreiheit: 155 Handkorrekturen in 53 Dateien (Symbole 71, Knopf-Typen 52, Beschriftungen 22, Rollen/Semantik 10) — je Fundstelle entschieden, ob ein Symbol dekorativ (`aria-hidden`) oder die einzige Beschriftung ist (`<title>`/`aria-label`); in der Seitenleiste erkannt, dass der Text beim Einklappen verschwindet, dort also ein echter Name noetig ist. Alle neuen Texte ueber next-intl in de **und** en (892/892 Schluessel). **Zahlen:** gesamt 2923 → 465, echter Quellcode 856 → 386, Testdateien 2067 → 79, Fehler-Rang durchgehend 0. **Bewusst NICHT angefasst, benannt statt stillschweigend:** 288 `noExplicitAny` im Quellcode (echte Typarbeit), 20 `useExhaustiveDependencies` (je ein moeglicher Effekt-Fehler), 30 a11y-Befunde mit Bedienentscheidungsbedarf, `noUselessSwitchCase` (Fallmarke dokumentiert Absicht), sowie fuenf tote Stellen, die Symptome echter Luecken sind — darunter: Passwortwechsel-Seite leitet nach erzwungenem Wechsel nicht weiter, Loeschknopf in `VehicleTable` ohne Besetztzustand, `force-password-change.interceptor` liest das HTTP-Verfahren und fragt es nie ab. **Klicktest am laufenden System** (echte Abbilder, Playwright): API meldet `healthy` und `Nest application successfully started` — Abhaengigkeitsspritze zur Laufzeit bewiesen; `Abbrechen` legt nichts an, `Speichern` legt an; als ADMIN bietet die SUPER_ADMIN-Zeile nur noch `Details`; abgewiesene Server-Antwort erscheint sichtbar als "Der Server hat die Aktion abgelehnt: …". Testbenutzer wieder geloescht. Verifikation passed. | 2026-09-21 | 8d1c8f3,636fe0d,+11 | [260921-bi2-lint-rueckstand-abbauen-mechanische-fixe](./quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/) |
## Deferred Items
@@ -491,4 +492,4 @@ Last session: 2026-09-21T04:50:00Z
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
Resume file: None
Last activity: 2026-09-21 - Quick 260921-a1d (WINDOWS #36): Benutzerverwaltung meldet abgewiesene Server-Antworten sichtbar (drei Stellen), ADMIN bekommt in der SUPER_ADMIN-Zeile keine Aktionsknoepfe mehr; apps/api unangetastet, Web-Tests 66/459 gruen
Last activity: 2026-09-21 - Quick 260921-bi2: Lint-Rueckstand 2923 → 465 Warnungen abgebaut (Konfiguration, maschinelle Fixes, toter Code, 155 Handkorrekturen Barrierefreiheit); Beinahe-Schaden abgewendet — Biomes als sicher eingestufte useImportType-Korrektur haette die NestJS-Abhaengigkeitsspritze zerstoert, unbemerkt von tsc und 1124 gruenen Tests
@@ -0,0 +1,680 @@
---
phase: quick-260921-bi2
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- biome.json
- docs/anleitung-entwicklung.md
# Aufgabe 2 — 53 Dateien: 45 maschinell + 11 toter Code, 3 in beiden (gemessen, siehe <verify>)
- apps/api/src/**
- apps/api/scripts/rls-scratch-check.mjs
- apps/web/src/**
- apps/desktop/src/setup.html
# Aufgabe 3 — Barrierefreiheit von Hand, 53 Dateien
- apps/web/src/app/**
- apps/web/src/components/**
- apps/web/src/app/icon.svg
autonomous: true
requirements: [LINT-BACKLOG]
estimate:
tokens: 205000
raw_tokens: 205000
tasks: 3
confidence: low
must_haves:
truths:
- "Der Lint-Rueckstand faellt von 2923 auf rund 466 Befunde; kein einziger davon hat die Stufe Fehler, der CI-Schritt Lint bleibt gruen (D-07)."
- "Die Ausnahme fuer Testdateien greift nachweislich NICHT in echten Quelltext: nach der Konfigurationsaenderung stehen weiterhin exakt 289 noExplicitAny-Befunde in echtem Quelltext, und 0 in Testdateien (D-01)."
- "Die NestJS-Abhaengigkeitsspritze ueberlebt den maschinellen Durchlauf unversehrt: die aus apps/api erzeugten __metadata-Zeilen sind vor und nach der Aenderung Zeichen fuer Zeichen identisch (593 Zeilen, sha256 6e1583f1...)."
- "Beide Testlaeufe bleiben punktgleich gruen: apps/api 69 Dateien / 1124 Tests, apps/web 66 Dateien / 459 Tests; pnpm type-check bleibt bei 4/4 (D-07)."
- "Die Regelgruppe security steht unveraendert auf error — weder im Regelblock noch in einer Ausnahme wird sie erwaehnt (D-06)."
- "Alle 155 Barrierefreiheits-Befunde der sechs bearbeiteten Regeln sind auf 0; keine a11y-Regel wurde dafuer herabgestuft oder abgeschaltet (D-04, D-06)."
- "In den drei Formularseiten der Verwaltung ist die Zahl der absendenden Schaltflaechen unveraendert (Mandanten 1, Benutzer 1, LDAP 2) — kein Formular hat seinen Absendeknopf verloren, keine Nebenschaltflaeche loest mehr versehentlich ein Absenden aus."
- "Es wurde keine repo-weite Formatierung angestossen; der Quelltext-Diff ist zeilenbilanziert (D-08)."
- "Die Entwickleranleitung nennt den neuen Stand, beide Ausnahmen mit Begruendung und die namentlich aufgefuehrten Folgeaufgaben."
artifacts:
- "biome.json — zwei zielgenaue overrides-Eintraege (Testdateien / apps-api) plus die korrigierte Fixture-Ausnahme; security unberuehrt"
- "docs/anleitung-entwicklung.md — Abschnitt zum Lint-Tor auf den neuen Stand gebracht, beide Ausnahmen begruendet, Folgeaufgaben benannt"
- "45 Quelldateien mit maschinell erzeugten, danach von Hand gelesenen mechanischen Korrekturen (Aufgabe 2)"
- "15 Fundstellen toten Codes entfernt bzw. begruendet gemeldet (Aufgabe 2, D-03)"
- "53 Dateien mit handgeschriebenen Barrierefreiheits-Korrekturen (Aufgabe 3, D-04)"
- "Ein Abschlussbericht mit Vorher/Nachher-Zahlen, getrennt nach Testdateien und echtem Quelltext, und der Liste dessen, was bewusst stehen bleibt"
key_links:
- "biome.json overrides[0].includes -> nur *.spec.ts/*.spec.tsx/*.test.ts/*.test.tsx: die Kette, an der D-01 still zu viel abschalten koennte; Nachweis ist die unveraenderte Zahl 289"
- "emitDecoratorMetadata (apps/api/tsconfig.json) -> __metadata(design:paramtypes) im erzeugten JS -> Nest loest Abhaengigkeiten auf: die Kette, die ein maschinelles import type in apps/api zerreisst; Nachweis ist der sha256-Vergleich"
- "biome lint --only=<regel> -> schaltet eine in der Konfiguration abgeschaltete Regel wieder AN: der Grund, warum useImportType pfadgebunden (apps/web packages) und nie repo-weit laufen darf"
- "<button> ohne Typangabe innerhalb eines <form> -> loest beim Klick ein Absenden aus: die Kette, die useButtonType repariert und die die Zahl der absendenden Schaltflaechen pro Datei belegt"
---
<objective>
Den Lint-Rueckstand abbauen, den der Vorgang 260921-9ie sichtbar gemacht hat, ohne das Verhalten der
Anwendung zu veraendern und ohne eine kosmetische Massenumschreibung.
Ausgangslage, heute frisch gemessen mit `biome lint . --reporter=json --max-diagnostics=20000`:
**2923 Befunde, davon 0 auf der Stufe Fehler** — 2067 in Testdateien, 856 in echtem Quelltext.
Der Plan geht in drei Durchgaengen vor, geordnet nach Risikoklasse, nicht nach Dateien:
| Durchgang | Art der Arbeit | Befunde danach (gesamt / echt / Test) |
|---|---|---|
| Start | — | 2923 / 856 / 2067 |
| 1 — Konfiguration | keine Zeile Quelltext angefasst | 754 / 633 / 121 |
| 2 — maschinell + toter Code | Werkzeug erzeugt, von Hand gelesen | 621 / 542 / 79 |
| 3 — Barrierefreiheit | vollstaendig Handarbeit | **466 / 387 / 79** |
Alle vier Zeilen sind gemessen, nicht geschaetzt: Durchgang 1 und 2 wurden beim Planen vollstaendig
probeweise ausgefuehrt, gemessen und danach restlos zurueckgenommen (`git restore .`, Arbeitsbaum
wieder sauber). Die Zahl fuer Durchgang 3 ist die Differenz der sechs bearbeiteten Regeln.
**Zwei Befunde aus der Probe, die den Zuschnitt bestimmen — beide belegt, nicht vermutet:**
1. **`style/useImportType` darf in `apps/api` NICHT maschinell angewendet werden.** Es ist mit 224
Befunden die groesste Einzelregel, 222 davon in `apps/api`. Biome stuft die Korrektur als
„sicher" ein, sie ist es hier aber nicht: `apps/api/tsconfig.json` hat `emitDecoratorMetadata`
eingeschaltet, und NestJS loest seine Abhaengigkeiten ueber genau diese erzeugten Daten auf.
Probelauf an `auth.service.ts`: vorher
`__metadata("design:paramtypes", [prisma_service_1.PrismaService, ...])`, nachher
`__metadata("design:paramtypes", [Function, Function, Function, Function, Function, Function])`,
und die `require`-Zeile des Dienstes verschwindet ersatzlos. Ueber den ganzen Workspace gemessen:
**61 von 65 Dateien mit Abhaengigkeitsdaten werden beschaedigt.** Die API startet danach nicht
mehr. Dabei bleibt `pnpm type-check` gruen, und **kein einziger der 1124 API-Tests faellt**, denn
`grep -rl createTestingModule apps/api/src` liefert 0 Treffer — der Testbestand startet den
NestJS-Container nirgends. Der Fehler waere also durch jedes bestehende Tor dieses Projekts
unbemerkt hindurchgegangen. Biome beschreibt das Problem in der eigenen Regelbeschreibung
(`biome explain useImportType`, Abschnitt „Caveat with TypeScript experimental decorators") und
empfiehlt dort woertlich, die Regel bei solchen Dekoratoren abzuschalten. Aufgabe 1 folgt dieser
Empfehlung mit einem auf `apps/api/**` begrenzten Eintrag. Das ist eine Abweichung von D-02 und
eine zweite Ausnahme neben D-01 — sie geschieht ausschliesslich, weil D-07 (kein
Verhaltenswechsel) Vorrang hat, nicht um eine Zahl zu druecken.
2. **Nur 4 der 10 in D-02 genannten Regeln haben ueberhaupt eine Korrektur der Klasse „sicher".**
Probelauf je Regel mit `--write` ohne `--unsafe`: `useImportType`, `noUselessEscapeInRegex`,
`useConst` und `useExponentiationOperator` aendern etwas; `useNodejsImportProtocol`,
`useLiteralKeys`, `useOptionalChain`, `useTemplate`, `noUselessSwitchCase` und `useParseIntRadix`
melden „No fixes applied" und brauchen `--unsafe`. Deren Gesamtdiff ist mit 45 Dateien und rund
100 Zeilen klein genug, um ihn vollstaendig von Hand zu lesen — was Aufgabe 2 verlangt.
Purpose: Das Lint-Tor ist seit dem Vorgang 260921-9ie scharf. Ein Rueckstand von 2923 Warnungen macht
es blind: eine neue, echte Warnung geht darin unter. Nach diesem Vorgang ist der Rueckstand klein
genug, um ihn zu ueberblicken, und der Rest ist namentlich benannt statt anonym.
Output: Bereinigte `biome.json`, rund 100 bereinigte Quelldateien, aktualisierte Entwickleranleitung,
ein ehrlicher Abschlussbericht.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@biome.json
@docs/anleitung-entwicklung.md
Messbefehl, der in allen drei Aufgaben gebraucht wird — einmal ausfuehren, Ergebnisdatei
wiederverwenden:
```bash
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
node -e '
const d=require(process.argv[1]).diagnostics;
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
const r=d.filter(x=>!T(x.location.path));
console.log("total",d.length,"| real",r.length,"| test",d.length-r.length,
"| errors",d.filter(x=>x.severity==="error").length);
const m={};for(const x of r)m[x.category]=(m[x.category]||0)+1;
console.log(Object.entries(m).sort((a,b)=>b[1]-a[1]).map(([k,v])=>String(v).padStart(4)+" "+k).join("\n"));
' "$LJ"
```
Wichtig zur Messung: Der Lauf muss aus dem Wurzelverzeichnis kommen. `pnpm lint` fuehrt
`biome lint .` je Workspace aus und erreicht `biome.json` selbst nicht; die Grundmessung oben tut es.
Ausserdem zwischenspeichert Turborepo den Lint-Schritt — fuer die Toraussage `pnpm lint --force`
verwenden, sonst meldet der Lauf ein altes Ergebnis.
</context>
<tasks>
<task type="tracer">
<name>Aufgabe 1: Konfiguration bereinigen und die Messgrundlage herstellen</name>
<files>biome.json, docs/anleitung-entwicklung.md</files>
<precondition>Der Arbeitsbaum ist sauber (`git status --porcelain` liefert nichts). Die drei Durchgaenge bauen aufeinander auf und werden je einzeln festgeschrieben; ein vorbelasteter Baum macht die Diff-Nachweise unlesbar.</precondition>
<action>
`biome.json` an drei Stellen aendern, sonst nirgends.
(a) **Ausnahme fuer Testdateien (D-01).** Einen `overrides`-Block anlegen, erster Eintrag:
`includes` auf die vier Muster `**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`,
darin `linter.rules.suspicious.noExplicitAny` auf `off`. Diese Muster sind gegen den Bestand
geprueft: `git ls-files` findet 71 Dateien auf `.spec.ts`, 50 auf `.test.tsx`, 14 auf `.test.ts` und
keine auf `.spec.tsx`; letzteres Muster bleibt der Symmetrie halber stehen. Weitere Testablagen gibt
es nicht — keine `__tests__`- und keine `__mocks__`-Verzeichnisse im Bestand.
(b) **Ausnahme fuer apps/api (Abweichung von D-02, begruendet in `<objective>` Punkt 1).** Zweiter
`overrides`-Eintrag: `includes` auf `apps/api/**`, darin `linter.rules.style.useImportType` auf
`off`. In denselben Eintrag gehoert keine weitere Regel. Der Grund muss als Satz in der
Entwickleranleitung stehen, nicht nur in der Festschreibung.
(c) **Fehlerhafte Fixture-Ausnahme reparieren.** `files.includes` enthaelt heute den Eintrag fuer das
Fixture-Verzeichnis mit angehaengtem Doppelstern. Biome meldet das selbst als
`lint/suspicious/useBiomeIgnoreFolder` in `biome.json:11` und nennt die richtige Form in seinem
Hinweis: seit Version 2.2.0 wird ein Verzeichnis ohne den angehaengten Doppelstern ausgenommen. Den
Eintrag entsprechend kuerzen. Das betrifft die sechs Dateien unter
`apps/api/src/tenders/__fixtures__/`.
Was NICHT geschieht (D-06): Der Block `linter.rules` wird an keiner anderen Stelle angefasst. Die
Gruppe `security` kommt in der Datei weder vorher noch nachher vor und erbt damit weiter `error` aus
`preset: recommended`. Die in 260921-9ie bewusst auf `warn` gesetzten Gruppen bleiben, wie sie sind.
Danach `docs/anleitung-entwicklung.md` im Abschnitt zum Lint-Tor (heute rund Zeile 61-69)
fortschreiben. Der Satz „Aktuell stehen rund 2800 solcher Warnungen offen" ist ab jetzt falsch. Der
neue Text nennt, in ganzen Saetzen und auf Deutsch: den Stand nach diesem Vorgang; dass `any` in
Testdateien absichtlich nicht mehr gemeldet wird, weil es dort ausnahmslos an Attrappen haengt und
eine Umschreibung viel Bewegung bei null Gewinn waere; dass `useImportType` in `apps/api`
abgeschaltet ist, weil die Korrektur dort die Abhaengigkeitsaufloesung von NestJS zerstoert, Biome
das in der eigenen Regelbeschreibung einraeumt und der Schaden von keinem Tor dieses Projekts
bemerkt wuerde; und die Liste dessen, was als eigener Durchgang folgt (siehe Aufgabe 3, Abschnitt
„Was bewusst stehen bleibt").
</action>
<verify>
<automated>
# 1. Konfiguration ist gueltiges JSON, hat genau zwei Ausnahmen, und security kommt nicht vor
node -e '
const c=require("/home/vicolab/projects/tessera-ctl/biome.json");
const ok=[];
ok.push(["overrides-Anzahl", (c.overrides||[]).length===2]);
ok.push(["Testmuster", JSON.stringify(c.overrides[0].includes)===JSON.stringify(["**/*.spec.ts","**/*.spec.tsx","**/*.test.ts","**/*.test.tsx"])]);
ok.push(["Testregel", c.overrides[0].linter.rules.suspicious.noExplicitAny==="off"]);
ok.push(["api-Pfad", JSON.stringify(c.overrides[1].includes)===JSON.stringify(["apps/api/**"])]);
ok.push(["api-Regel", c.overrides[1].linter.rules.style.useImportType==="off"]);
ok.push(["security nirgends genannt", !JSON.stringify(c).includes("security")]);
ok.push(["Fixture-Muster ohne Doppelstern", c.files.includes.includes("!**/__fixtures__")]);
let bad=0; for(const [n,v] of ok){ if(!v) bad++; console.log((v?"OK ":"FEHL")+" "+n); }
process.exit(bad);'
# 2. Zaehlung: Gesamt 754, echt 633, Test 121, Fehler 0
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
node -e '
const d=require(process.argv[1]).diagnostics;
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
const r=d.filter(x=>!T(x.location.path)).length, t=d.length-r;
const e=d.filter(x=>x.severity==="error").length;
const any=d.filter(x=>x.category==="lint/suspicious/noExplicitAny");
const anyReal=any.filter(x=>!T(x.location.path)).length, anyTest=any.length-anyReal;
console.log("gesamt",d.length,"echt",r,"test",t,"fehler",e,"| any echt",anyReal,"any test",anyTest);
const exp=[[d.length,754],[r,633],[t,121],[e,0],[anyReal,289],[anyTest,0]];
process.exit(exp.filter(([a,b])=>a!==b).length);' "$LJ"
# 3. Das Tor bleibt gruen (Turbo-Zwischenspeicher umgehen)
pnpm lint --force
pnpm type-check
</automated>
</verify>
<done>
`biome.json` traegt genau zwei `overrides`-Eintraege und die korrigierte Fixture-Ausnahme; das Wort
security kommt in der Datei nicht vor. Der Gesamtstand steht bei 754 Befunden (633 echt, 121 Test),
Fehlerstufe 0. Entscheidend: `noExplicitAny` steht weiterhin bei **exakt 289 in echtem Quelltext**
und 0 in Testdateien — die Ausnahme reicht nachweislich nicht in den Produktivcode hinein (D-01).
`pnpm lint --force` meldet 5/5, `pnpm type-check` 4/4. Die Entwickleranleitung nennt den neuen Stand
und begruendet beide Ausnahmen.
</done>
<reversibility rating="reversible">Eine Konfigurationsdatei und ein Dokumentabschnitt; beides ist mit einer Festschreibung zurueckgenommen.</reversibility>
</task>
<task type="auto">
<name>Aufgabe 2: Maschinelle Korrekturen und toter Code</name>
<files>
53 Dateien insgesamt. Davon 45 aus dem maschinellen Durchgang in apps/api/src, apps/api/scripts,
apps/web/src, packages und apps/desktop/src/setup.html — deren genauen Satz bestimmt das Werkzeug,
<verify> nagelt Anzahl und Zeilenbilanz fest. Namentlich dazu die 11 Fundstellen toten Codes
(D-03, drei davon stehen schon im maschinellen Satz):
apps/api/scripts/rls-scratch-check.mjs,
apps/api/src/auth/decorators/current-user.decorator.ts,
apps/api/src/auth/interceptors/force-password-change.interceptor.ts,
apps/api/src/calendar/calendar.service.ts,
apps/api/src/calendar/dto/create-calendar-source.dto.ts,
apps/api/src/cert-manager/cert-manager.service.ts,
apps/api/src/dkv/dkv-parser.service.ts,
apps/web/src/app/(auth)/login/page.tsx,
apps/web/src/app/(portal)/change-password/page.tsx,
apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx,
apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/VehicleTable.tsx
</files>
<precondition>Aufgabe 1 ist festgeschrieben und der Arbeitsbaum ist wieder sauber. Ohne die Ausnahme fuer `apps/api` aus Aufgabe 1 wuerde der erste Befehl dieses Durchgangs 61 von 65 Dateien mit Abhaengigkeitsdaten beschaedigen.</precondition>
<action>
**Erst die beiden Nachweis-Anker setzen, dann anfassen.**
Anker 1 — der Ausgangspunkt im Verlauf. `<verify>` misst den Umfang gegen diesen Punkt und nicht
gegen `HEAD`; sonst faellt die Messung leer aus, sobald die Arbeit festgeschrieben ist, und der
Umfangsnachweis geht ins Leere:
```bash
BASE=$(git rev-parse HEAD) # Stand nach Aufgabe 1; Wert notieren, in <verify> wiederverwenden
```
Anker 2 — der Abdruck der erzeugten Dekoratordaten. Er ist der eigentliche Beleg dafuer, dass sich
nichts am Verhalten geaendert hat, und ein gruener Testlauf ersetzt ihn nicht:
```bash
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
grep -rh '__metadata(' "$SNAP" | sort | sha256sum
# erwartet: 6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300 (593 Zeilen)
```
**(A) Durchgang mit gesicherten Korrekturen.** Vier Regeln haben eine Korrektur der Klasse „sicher";
sie duerfen ohne `--unsafe` laufen:
```bash
pnpm exec biome lint apps/web packages --only=lint/style/useImportType --write
for R in complexity/noUselessEscapeInRegex style/useConst style/useExponentiationOperator; do
pnpm exec biome lint . --only="lint/$R" --write --max-diagnostics=20000
done
```
Die erste Zeile ist **pfadgebunden, und das ist kein Schoenheitsfehler**: `--only=<regel>` schaltet
eine Regel, die in der Konfiguration auf `off` steht, fuer diesen Lauf wieder AN. Ein repo-weites
`biome lint . --only=lint/style/useImportType --write` haengt die Ausnahme aus Aufgabe 1 also
wirkungslos aus und schreibt 201 `import type`-Zeilen nach `apps/api` — beim Planen genau so
passiert und gemessen. Der Pfad `apps/web packages` ist die einzige zulaessige Form. Betroffen sind
dort 2 Fundstellen.
**(B) Durchgang mit ungesicherten Korrekturen, danach vollstaendig von Hand gelesen.** Sechs Regeln
haben keine gesicherte Korrektur. Fuenf davon laufen mit `--unsafe`:
```bash
for R in style/useNodejsImportProtocol complexity/useLiteralKeys complexity/useOptionalChain \
style/useTemplate correctness/useParseIntRadix; do
pnpm exec biome lint . --only="lint/$R" --write --unsafe --max-diagnostics=20000
done
```
Danach `git diff` **vollstaendig lesen** — es sind rund 100 Zeilen, das ist zumutbar und verlangt
(D-02: nie ungeprueft anwenden). Zwei Stellen brauchen dabei ausdruecklich Aufmerksamkeit, weil sie
in Wegen liegen, die dieses Projekt schon einmal Zeit gekostet haben:
- `apps/api/src/ldap/ldap.service.ts` traegt 25 der 31 `useLiteralKeys`-Aenderungen. Es geht um
Verzeichnis-Merkmale, die heute in eckigen Klammern gelesen werden und danach mit Punkt. Das ist in
JavaScript dieselbe Operation; die Schreibweise der Merkmalsnamen (`objectGUID`, `sAMAccountName`,
`cn`, `ou`, `mail`) darf sich dabei an **keiner** Stelle aendern — ein verschluckter Grossbuchstabe
macht den AD-Abgleich still leer. Zeichenweise gegenlesen.
- `apps/api/src/auth/strategies/jwt.strategy.ts` und `apps/api/src/auth/auth.service.ts` liegen im
Anmeldeweg. Dort entstehen Verkuerzungen wie „kein Benutzer oder Benutzer nicht aktiv" zu einer
einzigen abgesicherten Kette. Pruefen, dass jede dieser Verkuerzungen dieselbe Entscheidung faellt
wie vorher — insbesondere, dass eine fehlende Sitzung weiterhin zur Abweisung fuehrt und nicht zum
Durchwinken.
**Die sechste Regel wird bewusst NICHT angewendet.** `complexity/noUselessSwitchCase` meldet eine
Fundstelle in `apps/api/src/tenders/tender-normalizer.service.ts:60`. Die Regel moechte dort eine
Fallmarke streichen, die unmittelbar ueber einem Kommentar steht, der erklaert, warum der
Standardzweig genau auf diesem Weg bleiben muss. Die Marke dokumentiert also Absicht, die der Regel
entgeht. Sie bleibt stehen, wird nicht unterdrueckt und im Abschlussbericht als bewusster Nicht-Fix
genannt — das ist ehrlicher als eine Unterdrueckung, die wie eine Erledigung aussieht.
**(C) Toter Code (D-03), 15 Fundstellen.** Jede einzeln lesen, bevor etwas verschwindet. Drei
Gruppen:
1. *Echt tot, folgenlos entfernbar* — die nicht benutzten Fehlervariablen in
`calendar.service.ts` (Zeilen 321, 358), `cert-manager.service.ts` (305, 679) und
`dkv-parser.service.ts` (42): den Namen aus der Auffangklausel streichen, den Rumpf und damit den
Kontrollfluss unveraendert lassen. Dazu die nicht benutzten Einfuhren in
`create-calendar-source.dto.ts:2` und die nicht benutzte Funktion `forSystemQuery` in
`rls-scratch-check.mjs:226` (ein Pruefskript, nicht im Auslieferungsweg).
2. *Nicht entfernbar, nur umbenennen* — `current-user.decorator.ts:4`: der Wert `data` ist der
erste von zwei Parametern, die NestJS positionsgebunden uebergibt. Streichen wuerde den zweiten
verschieben. Stattdessen mit fuehrendem Unterstrich kennzeichnen.
3. *Symptome, die gemeldet und NICHT stillschweigend repariert werden* — hier ist die unbenutzte
Variable der Hinweis auf eine echte Luecke, und sie zu schliessen waere ein Verhaltenswechsel, den
D-07 diesem Vorgang verbietet:
- `force-password-change.interceptor.ts:53` liest das HTTP-Verfahren in eine Variable und
befragt sie nie; die Freigabeliste unterscheidet also nicht zwischen Lese- und Schreibzugriff
auf die freigegebenen Wege. Variable entfernen, Luecke im Bericht als Folgeaufgabe nennen.
- `change-password/page.tsx:11-12` haelt Wegweiser und Benutzerablage vor und benutzt beide
nicht. Beim Lesen der Datei bestaetigt: nach erfolgreichem Wechsel wird weder weitergeleitet
noch die Benutzerablage aufgefrischt — bei erzwungenem Wechsel bleibt die Person auf der Seite
stehen. Die drei Bindungen entfernen, den Befund im Bericht als Folgeaufgabe nennen.
- `VehicleTable.tsx:164` setzt einen Laufzustand fuers Loeschen, liest ihn aber nie; die
Loeschschaltflaeche hat also keinen Besetztzustand und laesst sich doppelt ausloesen. Nur die
lesende Bindung entfernen, die setzende bleibt; Befund im Bericht als Folgeaufgabe nennen.
- `SplitTab.tsx:20` bekommt die Uebersetzungsfunktion und benutzt sie nicht — ein Hinweis auf
fest verdrahtete Texte in diesem Reiter. Parameter entfernen, Befund melden.
- `login/page.tsx:21` haelt einen unbenutzten Wegweiser; hier ist die Weiterleitung
nachweislich anderswo geloest. Entfernen, keine Meldung noetig.
Kein `biome format --write`, auch nicht auf einzelne Dateien (D-08). Der Diff muss zeilenbilanziert
bleiben.
</action>
<verify>
<automated>
# 1. STAERKSTER NACHWEIS: die Abhaengigkeitsdaten von NestJS sind Zeichen fuer Zeichen unveraendert.
# Ein gruener Testlauf kann das NICHT belegen — kein Test dieses Projekts startet den Container
# (grep -rl createTestingModule apps/api/src liefert 0 Treffer).
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
test "$(grep -rh '__metadata(' "$SNAP" | wc -l)" = "593" || { echo "FEHL: Zeilenzahl der Dekoratordaten"; exit 1; }
grep -rh '__metadata(' "$SNAP" | sort | sha256sum \
| grep -q '^6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300' \
|| { echo "FEHL: Dekoratordaten veraendert — Abhaengigkeitsaufloesung gefaehrdet"; exit 1; }
echo "OK Dekoratordaten unveraendert (593 Zeilen)"
# 2. Umfang festgenagelt: 53 Quelldateien (45 aus dem maschinellen Durchgang + 11 toter Code,
# davon 3 in beiden), und der Diff hat keinen Zeilenueberschuss (keine Formatierung, D-08).
# $BASE ist der in <action> notierte Stand nach Aufgabe 1 — gegen HEAD gemessen waere die
# Pruefung leer und damit wertlos, sobald die Arbeit festgeschrieben ist.
# git-Ausgabe zuerst einfangen, damit ein Fehlschlag von git nicht in einer Pipe verschwindet.
test -n "$BASE" || { echo "FEHL: BASE nicht gesetzt (siehe <action>, Anker 1)"; exit 1; }
CHANGED=$(git diff --name-only "$BASE") || { echo "FEHL: git diff --name-only"; exit 1; }
N=$(printf '%s\n' "$CHANGED" | grep -v '^\(biome\.json\|docs/\)' | grep -c .)
test "$N" = "53" || { echo "FEHL: $N statt 53 Quelldateien"; printf '%s\n' "$CHANGED"; exit 1; }
STAT=$(git diff --numstat "$BASE" -- ':!biome.json' ':!docs') || { echo "FEHL: git diff --numstat"; exit 1; }
printf '%s\n' "$STAT" | awk '
{a+=$1;b+=$2}
END {print "Zugaenge",a,"Abgaenge",b;
if (a>b) {print "FEHL: Zeilenueberschuss — Formatierung mitgelaufen?"; exit 1}}'
# Zwischenprobe, direkt nach Schritt (B) und VOR dem toten Code auszufuehren:
# MID=$(git diff --name-only "$BASE") ; printf '%s\n' "$MID" | grep -vc '^biome\.json'
# -> muss 45 ergeben
# 3. Die neun bearbeiteten Regeln stehen auf 0, die bewusst stehen gelassene auf 1
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
node -e '
const d=require(process.argv[1]).diagnostics;
const c=r=>d.filter(x=>x.category==="lint/"+r).length;
const soll={"style/useImportType":0,"style/useNodejsImportProtocol":0,"complexity/useLiteralKeys":0,
"complexity/useOptionalChain":0,"style/useTemplate":0,"complexity/noUselessEscapeInRegex":0,
"style/useConst":0,"style/useExponentiationOperator":0,"correctness/useParseIntRadix":0,
"correctness/noUnusedVariables":0,"correctness/noUnusedImports":0,
"correctness/noUnusedFunctionParameters":0,"complexity/noUselessSwitchCase":1};
let bad=0; for(const [r,s] of Object.entries(soll)){const v=c(r); if(v!==s){bad++;console.log("FEHL",r,v,"statt",s);} }
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
const re=d.filter(x=>!T(x.location.path)).length;
console.log("gesamt",d.length,"echt",re,"test",d.length-re,"fehler",d.filter(x=>x.severity==="error").length);
if(d.length!==621||re!==542) {bad++;console.log("FEHL Gesamtstand, erwartet 621/542");}
if(d.filter(x=>x.severity==="error").length!==0){bad++;console.log("FEHL Fehlerstufe nicht 0");}
process.exit(bad);' "$LJ"
# 4. Unveraenderte gruene Balken
pnpm --filter @tessera/api run test 2>&1 | grep -q "Test Files 69 passed (69)" || { echo "FEHL api Dateien"; exit 1; }
pnpm --filter @tessera/api run test 2>&1 | grep -q "Tests 1124 passed (1124)" || { echo "FEHL api Tests"; exit 1; }
pnpm --filter @tessera/web run test 2>&1 | grep -q "Test Files 66 passed (66)" || { echo "FEHL web Dateien"; exit 1; }
pnpm --filter @tessera/web run test 2>&1 | grep -q "Tests 459 passed (459)" || { echo "FEHL web Tests"; exit 1; }
pnpm type-check
pnpm lint --force
</automated>
</verify>
<done>
Die erzeugten Dekoratordaten aus `apps/api` sind unveraendert (593 Zeilen, sha256 `6e1583f1...`) — der
maschinelle Durchgang hat die Abhaengigkeitsaufloesung von NestJS nachweislich nicht angetastet.
Genau 45 Quelldateien sind geaendert, der Quelltext-Diff ist zeilenbilanziert (keine Formatierung
mitgelaufen). Die neun bearbeiteten Regeln stehen auf 0, `noUselessSwitchCase` bewusst auf 1. Der
Gesamtstand liegt bei 621 Befunden (542 echt, 79 Test), Fehlerstufe 0. Beide Testlaeufe sind
punktgleich gruen (69/1124 und 66/459), `pnpm type-check` 4/4, `pnpm lint --force` 5/5. Die fuenf
Symptomfunde aus D-03 sind im Bericht namentlich als Folgeaufgaben festgehalten, nicht stillschweigend
repariert.
</done>
<reversibility rating="costly">45 Dateien maschinell veraendert. Zuruecknehmen heisst eine Festschreibung verwerfen — technisch einfach, aber der von Hand gelesene Diff waere noch einmal zu lesen. Deshalb steht der Abdruck der Dekoratordaten VOR der ersten Aenderung.</reversibility>
</task>
<task type="auto">
<name>Aufgabe 3: Barrierefreiheit von Hand</name>
<files>
53 Dateien, ueberwiegend apps/web/src/components und apps/web/src/app, dazu
apps/web/src/app/icon.svg und apps/desktop/src/setup.html.
Schwerpunkte (Befunde je Datei):
apps/web/src/app/(portal)/admin/ldap/page.tsx (15),
apps/web/src/app/(portal)/admin/users/page.tsx (11),
apps/web/src/components/settings/calendar-settings-panel.tsx (11),
apps/web/src/components/layout/sidebar.tsx (9),
apps/web/src/app/(portal)/admin/tenants/page.tsx (8),
apps/web/src/components/dashboard/widget-catalog-modal.tsx (8),
apps/web/src/components/layout/header.tsx (8),
apps/web/src/components/admin/admin-sidebar.tsx (7),
apps/web/src/components/dashboard/widget-registry.tsx (7)
</files>
<precondition>Aufgabe 2 ist festgeschrieben und der Arbeitsbaum ist sauber. Die Zaehlungen in diesem Durchgang gehen vom Stand 621/542/79 aus.</precondition>
<action>
Zuerst den Ausgangspunkt im Verlauf festhalten, damit `<verify>` den Umfang messen kann, auch wenn
die Arbeit zwischendurch festgeschrieben wird:
```bash
BASE3=$(git rev-parse HEAD) # Stand nach Aufgabe 2
```
Hier hilft kein Werkzeug: fuer alle sechs Regeln dieses Durchgangs liefert Biome weder eine
gesicherte noch eine ungesicherte Korrektur — beim Planen je Regel mit `--write` und mit
`--write --unsafe` geprueft, Ergebnis jeweils null geaenderte Dateien (einzige Ausnahme
`noRedundantRoles` mit 3 Dateien unter `--unsafe`). **155 Fundstellen in 53 Dateien, von Hand.**
Bearbeitet werden diese sechs Regeln:
| Regel | Befunde | Vorgehen |
|---|---|---|
| `a11y/noSvgWithoutTitle` | 71 | je Symbol entscheiden |
| `a11y/useButtonType` | 52 | je Schaltflaeche entscheiden |
| `a11y/noLabelWithoutControl` | 22 | Beschriftung an Feld binden |
| `a11y/useAriaPropsSupportedByRole` | 5 | Merkmal oder Rolle richtigstellen |
| `a11y/noRedundantRoles` | 4 | doppelte Rollenangabe entfernen |
| `a11y/useAriaPropsForRole` | 1 | fehlendes Pflichtmerkmal ergaenzen |
**Symbole (71).** Alle Symbole in diesem Projekt sind eingebettete Grafiken; eine Symbolbibliothek
gibt es nicht. Je Fundstelle eine von zwei Entscheidungen, und die Entscheidung haengt daran, ob
daneben schon Text steht:
- Das Symbol begleitet sichtbaren Text (Menueeintrag mit Beschriftung, Schaltflaeche mit Wort) →
es ist Schmuck und wird fuer die Vorlesehilfe ausgeblendet. Sonst liest sie die Sache doppelt.
- Das Symbol steht allein und traegt die ganze Bedeutung (Schliessen-Kreuz, Zahnrad, Lupe ohne
Wort) → es bekommt einen Titel, der sagt, was die Bedienung tut, nicht wie sie aussieht. Der Text
gehoert in den Uebersetzungskatalog, wenn die umgebende Datei bereits uebersetzt; sonst als
deutscher Klartext, passend zum Umfeld.
Zwei Fundstellen liegen ausserhalb der React-Oberflaeche: `apps/web/src/app/icon.svg` (die
Bildmarke — Titel mit dem Produktnamen) und `apps/desktop/src/setup.html:173` (Einrichtungsseite des
Desktop-Programms).
**Schaltflaechen (52).** Hier ist die Regel kein Formfehler, sondern deckt echte Fehlbedienung auf:
eine Schaltflaeche ohne Typangabe innerhalb eines Formulars sendet das Formular ab. Deshalb je
Fundstelle lesen, was die Schaltflaeche tatsaechlich tun soll, und entsprechend auszeichnen —
nicht pauschal dasselbe eintragen. Nur drei der 25 betroffenen Dateien enthalten ueberhaupt ein
Formular:
- `admin/tenants/page.tsx` — 1 Formular, 6 Fundstellen
- `admin/users/page.tsx` — 1 Formular, 6 Fundstellen
- `admin/ldap/page.tsx` — 2 Formulare, 4 Fundstellen
In diesen drei Dateien ist der Absendeknopf jeweils **schon richtig ausgezeichnet** (gezaehlt:
1 / 1 / 2). Die 16 Fundstellen dort sind also durchweg Neben-Schaltflaechen — Abbrechen, Schliessen,
Zeilenaktionen —, die heute beim Klick ungewollt absenden. Sie bekommen die Auszeichnung fuer „tut
etwas anderes". Damit aendert sich Verhalten, und zwar genau das kaputte: das ist der Zweck der
Regel und ausdruecklich von D-04 gedeckt. Die Zahl der absendenden Schaltflaechen je Datei muss
danach unveraendert bei 1 / 1 / 2 stehen — das prueft `<verify>`. In den uebrigen 22 Dateien gibt es
kein Formular; dort ist die Auszeichnung reine Absicherung.
**Beschriftungen (22).** Beschriftung und Eingabefeld ueber eine Kennung verbinden, oder die
Beschriftung um das Feld legen. Kennungen muessen je Seite eindeutig bleiben — in Listen mit
wiederholten Zeilen die Zeilenkennung in die Feldkennung aufnehmen. Danach gehoert zu jeder
Beschriftung genau ein Feld.
**Rollen und Merkmale (10).** Die fuenf Merkmale, die zur gesetzten Rolle nicht passen, das eine
fehlende Pflichtmerkmal und die vier doppelt gesetzten Rollen einzeln richtigstellen. Bei
`noRedundantRoles` darf der maschinelle Vorschlag als Ausgangspunkt dienen
(`pnpm exec biome lint apps/web --only=lint/a11y/noRedundantRoles --write --unsafe`, 3 Dateien),
das Ergebnis ist trotzdem zu lesen.
**Was bewusst stehen bleibt (30 Befunde, D-05).** Diese sechs Regeln werden NICHT bearbeitet, weil
jede eine Gestaltungsentscheidung oder einen Verhaltenswechsel verlangt, den dieser Vorgang nicht
treffen darf:
- `noNoninteractiveElementInteractions` (11) und `noStaticElementInteractions` (5) — verlangen die
Entscheidung, ob aus einem geklickten Bereich eine echte Bedienung wird oder der Klick weg soll.
- `useKeyWithClickEvents` (5) — verlangt einen Tastaturweg, den es heute nicht gibt; das ist neue
Bedienung, kein Aufraeumen.
- `noAutofocus` (4) — das Entfernen verschiebt den Eingabefokus beim Seitenaufruf und ist damit ein
Verhaltenswechsel, den D-07 hier verbietet.
- `useSemanticElements` (4) — verlangt einen Austausch von Bauelementen mitsamt Gestaltung.
- `noNoninteractiveTabindex` (1) — aendert die Tabulatorreihenfolge.
Diese 30 gehoeren in den Bericht und in die Entwickleranleitung, nicht in eine stille Ablage. Keine
dieser Regeln wird herabgestuft oder abgeschaltet (D-06).
**Abschlussbericht.** Zum Schluss den Vorher/Nachher-Stand festhalten: 2923 → 466, getrennt nach
echtem Quelltext (856 → 387) und Testdateien (2067 → 79), mit der Aufschluesselung dessen, was
bleibt, und den in Aufgabe 2 gefundenen Symptomen als benannte Folgeaufgaben. Denselben Stand in
`docs/anleitung-entwicklung.md` nachziehen, falls die in Aufgabe 1 geschriebene Zahl abweicht.
</action>
<verify>
<automated>
# 1. Die sechs bearbeiteten Regeln stehen auf 0, die sechs zurueckgestellten unveraendert
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
node -e '
const d=require(process.argv[1]).diagnostics;
const c=r=>d.filter(x=>x.category==="lint/a11y/"+r).length;
const erledigt={noSvgWithoutTitle:0,useButtonType:0,noLabelWithoutControl:0,
useAriaPropsSupportedByRole:0,noRedundantRoles:0,useAriaPropsForRole:0};
const zurueck={noNoninteractiveElementInteractions:11,noStaticElementInteractions:5,
useKeyWithClickEvents:5,noAutofocus:4,useSemanticElements:4,noNoninteractiveTabindex:1};
let bad=0;
for(const [r,s] of Object.entries(erledigt)) if(c(r)!==s){bad++;console.log("FEHL erledigt",r,c(r),"statt",s);}
for(const [r,s] of Object.entries(zurueck)) if(c(r)!==s){bad++;console.log("FEHL zurueckgestellt",r,c(r),"statt",s);}
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
const re=d.filter(x=>!T(x.location.path)).length;
console.log("ENDSTAND gesamt",d.length,"echt",re,"test",d.length-re,
"fehler",d.filter(x=>x.severity==="error").length);
if(d.length!==466||re!==387){bad++;console.log("FEHL Endstand, erwartet 466/387");}
if(d.filter(x=>x.severity==="error").length!==0){bad++;console.log("FEHL Fehlerstufe nicht 0");}
process.exit(bad);' "$LJ"
# 2. Keine a11y-Regel wurde herabgestuft, um diese Zahlen zu erreichen (D-06)
node -e '
const c=require("/home/vicolab/projects/tessera-ctl/biome.json");
const s=JSON.stringify(c.linter.rules.a11y)+JSON.stringify(c.overrides);
const ok = c.linter.rules.a11y==="warn" && !s.includes("a11y/") && !/"a11y"\s*:\s*\{/.test(JSON.stringify(c.overrides));
console.log(ok?"OK a11y unveraendert auf warn, keine Einzelausnahme":"FEHL a11y angefasst");
process.exit(ok?0:1);'
# 3. Kein Formular hat seinen Absendeknopf verloren, keine Nebenschaltflaeche sendet mehr ab
cd /home/vicolab/projects/tessera-ctl
for f in "apps/web/src/app/(portal)/admin/tenants/page.tsx:1" \
"apps/web/src/app/(portal)/admin/users/page.tsx:1" \
"apps/web/src/app/(portal)/admin/ldap/page.tsx:2"; do
p="${f%:*}"; soll="${f##*:}"
ist=$(grep -c 'type="submit"' "$p")
formulare=$(grep -c '<form' "$p")
test "$ist" = "$soll" && test "$formulare" = "$soll" \
|| { echo "FEHL $p: $ist absendende Schaltflaechen bei $formulare Formularen, erwartet $soll"; exit 1; }
echo "OK $p $ist/$formulare"
done
# 4. Der API-Teil ist in diesem Durchgang gar nicht angefasst worden.
# $BASE3 ist der in <action> notierte Stand nach Aufgabe 2; gegen HEAD gemessen liefe die
# Pruefung nach dem Festschreiben leer. git-Ausgabe zuerst einfangen — ein Fehlschlag von git
# saehe sonst aus wie "nichts geaendert".
test -n "$BASE3" || { echo "FEHL: BASE3 nicht gesetzt (siehe <action>)"; exit 1; }
API_DIFF=$(git diff --name-only "$BASE3" -- apps/api packages) || { echo "FEHL: git diff"; exit 1; }
test -z "$API_DIFF" || { echo "FEHL: apps/api oder packages veraendert:"; printf '%s\n' "$API_DIFF"; exit 1; }
# 5. Unveraenderte gruene Balken
pnpm --filter @tessera/web run test 2>&1 | grep -q "Test Files 66 passed (66)" || { echo "FEHL web Dateien"; exit 1; }
pnpm --filter @tessera/web run test 2>&1 | grep -q "Tests 459 passed (459)" || { echo "FEHL web Tests"; exit 1; }
pnpm --filter @tessera/api run test 2>&1 | grep -q "Tests 1124 passed (1124)" || { echo "FEHL api Tests"; exit 1; }
pnpm type-check
pnpm lint --force
</automated>
<human-check>
Die 52 Schaltflaechen-Aenderungen sind die einzige Stelle dieses Vorgangs, an der sich absichtlich
Verhalten aendert, und ein gruener Testlauf deckt davon nur einen Teil ab. Darum im Entwicklungsbetrieb
(`pnpm dev`, nicht gegen den Testserver) die drei Formularseiten der Verwaltung oeffnen — Mandanten,
Benutzer, LDAP — und je Seite zwei Dinge pruefen: das Formular laesst sich weiterhin ueber seinen
Absendeknopf abschicken, und ein Klick auf Abbrechen beziehungsweise Schliessen schickt es NICHT ab.
Nicht ueber Netzwerkaufrufe aus der Seite heraus messen, sondern die Oberflaeche bedienen.
</human-check>
</verify>
<done>
Die sechs bearbeiteten Barrierefreiheits-Regeln stehen auf 0 (155 Fundstellen in 53 Dateien von Hand
erledigt), die sechs zurueckgestellten stehen unveraendert bei zusammen 30 und sind im Bericht
namentlich mit Begruendung aufgefuehrt. Keine a11y-Regel wurde dafuer herabgestuft oder in eine
Ausnahme gelegt. In den drei Formularseiten steht die Zahl der absendenden Schaltflaechen unveraendert
bei 1 / 1 / 2, und die Bedienprobe bestaetigt, dass Absenden weiter geht und Abbrechen nicht mehr
absendet. `apps/api` und `packages` sind in diesem Durchgang unberuehrt. Endstand: **466 Befunde
(387 echt, 79 Test), Fehlerstufe 0** — von 2923 zu Beginn. Beide Testlaeufe punktgleich gruen,
`pnpm type-check` 4/4, `pnpm lint --force` 5/5.
</done>
<reversibility rating="reversible">Handarbeit an der Oberflaeche, ausschliesslich apps/web und apps/desktop; jede Datei einzeln zuruecknehmbar.</reversibility>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Lint-Konfiguration → CI-Tor | `biome.json` entscheidet, was der CI-Schritt „Lint" ueberhaupt sehen kann. Eine zu weit gefasste Ausnahme blendet echte Befunde dauerhaft aus, ohne dass jemand es bemerkt. |
| Werkzeug → Quelltext | `biome lint --write` aendert Dateien ohne menschlichen Zwischenschritt, auch in Anmelde-, Sitzungs- und Verzeichnisabgleich-Wegen. |
| Erzeugtes JS → NestJS-Laufzeit | Die Abhaengigkeitsaufloesung liest Daten, die der Compiler aus Typangaben erzeugt. Kein Tor dieses Projekts prueft diese Daten heute. |
| Browser → Formular | Eine Schaltflaeche ohne Typangabe loest im Formular ein Absenden aus; ihre Auszeichnung entscheidet, ob ein Klick Daten schreibt. |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-bi2-01 | Tampering | `biome.json` overrides[1] (`apps/api/**`, `useImportType` off) | high | mitigate | Nur moeglich, weil die Alternative beweisbar schlimmer ist: 61 von 65 Dateien mit Abhaengigkeitsdaten werden vom maschinellen Fix zerstoert, bei gruenem `tsc` und gruenen 1124 Tests. Begrenzt auf genau eine Regel und einen Pfad; `<verify>` in Aufgabe 1 prueft die Form des Eintrags und dass er keine zweite Regel enthaelt. Begruendung steht schriftlich in der Entwickleranleitung, nicht nur in der Festschreibung. |
| T-bi2-02 | Tampering | NestJS-Abhaengigkeitsdaten, gesamter maschineller Durchgang | high | mitigate | sha256 ueber alle 593 erzeugten `__metadata`-Zeilen vor und nach dem Durchgang; muss identisch bleiben (`6e1583f1...`). Beim Planen mit dem vollstaendigen Durchgang erprobt: Abdruck blieb identisch. Das ist der einzige Nachweis, der hier traegt — `grep -rl createTestingModule apps/api/src` liefert 0 Treffer, der Testbestand startet den Container nirgends. |
| T-bi2-03 | Information Disclosure | `biome.json` overrides[0] (Testdateien) | medium | mitigate | Die Ausnahme koennte echten Produktivcode mit abdecken. Geprueft und ausgeschlossen: keine Datei auf `.spec`/`.test` exportiert ein Symbol (`grep -rln "^export" --include=*.spec.ts --include=*.test.ts --include=*.test.tsx` liefert nichts), und keine Datei ausserhalb dieser Muster fuehrt eine solche Datei ein. Laufender Nachweis: `noExplicitAny` bleibt bei exakt 289 in echtem Quelltext — waere die Ausnahme zu weit, faellt diese Zahl. Sie deckt genau eine Regel ab, nicht die Gruppe. |
| T-bi2-04 | Tampering | `apps/api/src/ldap/ldap.service.ts` (25 `useLiteralKeys`-Aenderungen) | high | mitigate | Der AD-Abgleich liest Verzeichnis-Merkmale ueber ihren Namen; eine verschluckte Grossschreibung macht ihn still leer, und AD ist in diesem Projekt bewusst nur lesend angebunden, also faellt es erst beim Anmelden auf. Diff zeichenweise gegenlesen (in `<action>` verlangt), zusaetzlich `ldap.service.spec.ts` im 1124er-Lauf und `tsc` ueber die getypten Zugriffe. |
| T-bi2-05 | Elevation of Privilege | `jwt.strategy.ts`, `auth.service.ts` (`useOptionalChain`, `useLiteralKeys`) | high | mitigate | Die Verkuerzungen liegen in Sitzungs-Cookie-Lesung und Aktiv-Pruefung des Kontos. Falsch verkuerzt liesse eine fehlende Sitzung durch. Der Gesamtdiff ist rund 100 Zeilen und wird vollstaendig gelesen; diese beiden Dateien sind in `<action>` namentlich als Lesepflicht markiert. `auth.service.spec.ts` und `jwt.strategy.spec.ts` laufen im 1124er-Bestand mit. |
| T-bi2-06 | Tampering | Entfernen „unbenutzter" Werte (D-03, 15 Stellen) | medium | mitigate | Ein Wert kann eine Nebenwirkung tragen. Darum drei Gruppen mit unterschiedlicher Behandlung statt eines pauschalen Loeschens: folgenlose Auffangvariablen werden entfernt, der positionsgebundene Dekoratorparameter wird nur umbenannt statt gestrichen, und fuenf Fundstellen sind Symptome echter Luecken und werden gemeldet statt repariert. `<verify>` nagelt den Diff auf 45 Dateien und Zeilenbilanz fest. |
| T-bi2-07 | Tampering | 52 Schaltflaechen in `apps/web` (`useButtonType`) | medium | mitigate | Eine falsch ausgezeichnete Schaltflaeche kann ein Formular entweder unabsendbar machen oder weiter ungewollt absenden lassen. Nur 3 der 25 Dateien enthalten ueberhaupt ein Formular; dort wird die Zahl der absendenden Schaltflaechen auf 1 / 1 / 2 festgenagelt, und eine Bedienprobe prueft beide Richtungen (Absenden geht, Abbrechen sendet nicht). |
| T-bi2-08 | Repudiation | Regelgruppe `security`, gesamte Konfiguration | high | mitigate | Der naheliegende Missbrauch dieses Vorgangs waere, Zahlen ueber Herabstufungen zu druecken. Dagegen drei Pruefungen: die Zeichenkette `security` darf in `biome.json` ueberhaupt nicht vorkommen (erbt damit `error` aus dem Vorgabesatz), `linter.rules.a11y` muss unveraendert auf `warn` stehen, und in `overrides` darf keine a11y-Regel auftauchen. Genau zwei Ausnahmen sind zugelassen, beide namentlich in `<verify>` von Aufgabe 1 beschrieben. |
| T-bi2-09 | Denial of Service | CI-Schritt „Lint" | medium | mitigate | Ein neuer Befund der Stufe Fehler faerbt CI rot. Jede der drei Aufgaben prueft `errors === 0` in der JSON-Zaehlung und laesst zusaetzlich `pnpm lint --force` laufen — mit `--force`, weil Turborepo den Schritt sonst aus dem Zwischenspeicher als gruen meldet. |
</threat_model>
<verification>
Nach der letzten Aufgabe, aus dem Wurzelverzeichnis:
```bash
LJ=$(mktemp /tmp/tessera-lint-XXXXXX.json)
pnpm exec biome lint . --reporter=json --max-diagnostics=20000 2>/dev/null > "$LJ"
node -e '
const d=require(process.argv[1]).diagnostics;
const T=p=>/\.(spec|test)\.(ts|tsx)$/.test(p);
const r=d.filter(x=>!T(x.location.path)).length;
console.log("gesamt",d.length,"(Start 2923) | echt",r,"(Start 856) | test",d.length-r,"(Start 2067)");
console.log("fehler",d.filter(x=>x.severity==="error").length,"(muss 0 sein)");
' "$LJ"
SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP" >/dev/null 2>&1
grep -rh '__metadata(' "$SNAP" | sort | sha256sum # 6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300
pnpm type-check # 4/4
pnpm lint --force # 5/5, 0 Fehler
pnpm --filter @tessera/api run test # 69 Dateien / 1124 Tests
pnpm --filter @tessera/web run test # 66 Dateien / 459 Tests
```
Zur Ehrlichkeit des Nachweises: Ein gruener Testbestand allein belegt fuer einen maschinellen
Durchgang ueber 45 Dateien **nicht**, dass sich nichts geaendert hat — dieser Plan hat beim Planen
einen Fall gemessen, der genau das zeigt (`import type` in `apps/api` laesst `tsc` und alle 1124
Tests gruen und zerstoert trotzdem den Start der Anwendung). Die tragenden Nachweise sind deshalb,
in dieser Reihenfolge: der unveraenderte Abdruck der Dekoratordaten, der zeilenbilanzierte und
vollstaendig von Hand gelesene Diff, die regelweisen Zaehlungen, und erst danach die gruenen
Testlaeufe.
</verification>
<success_criteria>
- Lint-Rueckstand von 2923 auf 466 gesenkt (echt 856 → 387, Test 2067 → 79), Fehlerstufe durchgehend 0.
- `biome.json` traegt genau zwei zielgenaue Ausnahmen; `security` kommt in der Datei nicht vor; keine a11y-Regel herabgestuft.
- `noExplicitAny` steht nach der Testdatei-Ausnahme unveraendert bei 289 in echtem Quelltext.
- Die erzeugten NestJS-Dekoratordaten sind Zeichen fuer Zeichen unveraendert (593 Zeilen, sha256 `6e1583f1...`).
- `pnpm type-check` 4/4, `pnpm lint --force` 5/5, apps/api 69/1124, apps/web 66/459 — alle punktgleich zum Ausgangsstand.
- Quelltext-Diff zeilenbilanziert; keine repo-weite Formatierung angestossen.
- Bericht nennt Vorher/Nachher getrennt nach Testdateien und echtem Quelltext, die 30 zurueckgestellten a11y-Befunde mit Begruendung, die fuenf Symptomfunde aus D-03 und die Regeln, die als eigener Durchgang folgen.
- Entwickleranleitung gibt den neuen Stand und beide Ausnahmen wieder.
</success_criteria>
<output>
Create `.planning/quick/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/260921-bi2-SUMMARY.md` when done
</output>
</content>
</invoke>
@@ -0,0 +1,439 @@
---
phase: quick-260921-bi2
plan: 01
subsystem: tooling
tags: [biome, lint, a11y, nestjs, decorator-metadata, i18n]
requires: []
provides:
- "biome.json mit genau zwei zielgenauen overrides (Testdateien / apps/api) statt eines
unueberschaubaren 2923er-Rueckstands"
- "45 maschinell erzeugte, von Hand gelesene Korrekturen ueber vier sichere und fuenf
unsichere Biome-Regeln"
- "15 untersuchte Fundstellen toten Codes: 10 folgenlos entfernt, 1 umbenannt (NestJS-
Parameterposition), 4 als benannte Folgeaufgaben gemeldet statt repariert"
- "155 von Hand erledigte Barrierefreiheits-Fundstellen ueber sechs Regeln (Symbole,
Schaltflaechentyp, Beschriftungsbindung, Rollen/ARIA-Merkmale, Tab-Reihenfolge)"
- "Entwickleranleitung auf Endstand 465/386/79 gebracht, beide Ausnahmen begruendet,
Folgeaufgaben benannt"
affects: [ci-lint-gate, apps/web-a11y, apps/api-di]
actuals:
tokens: 37573
tasks: 3
commits: 13
tech-stack:
added: []
patterns:
- "biome.json overrides sind pfadgebunden (apps/api/**), nie repo-weit mit --only=<regel>
angewendet -- --only schaltet eine per Konfiguration abgeschaltete Regel wieder an."
- "Bei einer Regel ohne gesicherten Fix zuerst den Diff lesen (--write ohne --unsafe testen),
danach --unsafe pruefen, danach den ganzen Diff lesen -- nie ungeprueft anwenden."
- "a11y-Fixes koennen sich gegenseitig verschieben: eine Regel loesen kann eine ANDERE
(zurueckgestellte) Regel neu anschlagen, wenn ein Element seine interaktive
Klassifikation verliert (role=button entfernt -> noStaticElementInteractions greift
auf die verbliebenen Drag-Handler). Nach jedem Einzel-Fix die komplette Regelmenge
(in Scope UND zurueckgestellt) neu messen, nicht nur die Zielregel."
key-files:
created: []
modified:
- biome.json
- docs/anleitung-entwicklung.md
- "apps/api/src/** (45 maschinelle Korrekturen + toter Code, Aufgabe 2)"
- "apps/web/src/** (a11y, Aufgabe 3, beide Teillieferungen)"
- apps/desktop/src/setup.html
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
key-decisions:
- "useImportType in apps/api/** abgeschaltet statt maschinell angewendet: emitDecoratorMetadata
erzeugt __metadata(design:paramtypes,...), das NestJS zur Abhaengigkeitsaufloesung liest;
import type entfernt den Wert-Import und ersetzt die erzeugten Typen durch Function-
Platzhalter. 61 von 65 Dateien mit Abhaengigkeitsdaten waeren beschaedigt worden, bei
gruenem tsc und gruenen 1124 Tests (kein Test ruft createTestingModule auf) -- der Fehler
waere durch jedes Tor dieses Projekts unbemerkt hindurchgegangen."
- "noUselessSwitchCase (1 Fundstelle, tender-normalizer.service.ts:60) bewusst NICHT
angewendet: die Fallmarke dokumentiert, warum der Standardzweig auf diesem Weg bleiben
muss; die Regel sieht nur den Code, nicht die Absicht dahinter."
- "useSemanticElements (4 Fundstellen) wurde entgegen der urspruenglichen Einordnung in
PLAN.md (dort unter D-05 zurueckgestellt) in Teillieferung B doch bearbeitet -- Abweichung
von der Vorgabe, siehe Deviations."
- "DropZone.tsx: Datei-Entfernen-Schaltflaeche als Geschwister statt verschachtelt in der
Drop-Flaeche (kein <button> darf ein zweites <button> enthalten); Drag-Handler wanderten
von der Flaechen-<div> auf die Flaechen-<button>, sonst waere ein 'statisches Element mit
Ereignis-Handler' entstanden und haette zwei zurueckgestellte Regeln neu ausgeloest."
- "admin-sidebar.tsx/settings-sidebar.tsx: role=navigation vom <aside> aufs bereits
vorhandene <nav> verschoben statt das <aside> in ein zweites <nav> zu verwandeln --
sonst waere ein doppeltes Navigations-Landmark entstanden."
requirements-completed: [LINT-BACKLOG]
duration: ~150min (Teillieferung B; Gesamtvorgang laenger, ueber mehrere Sitzungen)
completed: 2026-09-21
status: complete
---
# Quick-Vorgang 260921-bi2: Lint-Rueckstand abbauen (mechanische Fixe) Summary
**Lint-Rueckstand von 2923 auf 465 Befunde gesenkt (0 Fehlerstufe durchgehend) — durch eine
begruendete, pfadgebundene Konfigurationsbereinigung, einen von Hand gelesenen maschinellen
Durchgang und 155 handgeschriebene Barrierefreiheits-Korrekturen, ohne die NestJS-
Abhaengigkeitsaufloesung anzutasten.**
## Performance
- **Aufgabe 1 (Konfiguration):** Commit `8d1c8f3`
- **Aufgabe 2 (maschinell + toter Code):** Commits `3811578`, `636fe0d`
- **Aufgabe 3, Teillieferung A (a11y — Symbole, Schaltflaechentyp, Layout/Formulare):**
Commits `e76f3b8`, `4cff316`, `ae82125`, `278aedb`, `969fd01`, `27a6e29`, `73ac08a`
- **Aufgabe 3, Teillieferung B (a11y — Rollen, Semantik, Tab-Reihenfolge, Beschriftungsbindung):**
Commits `21c85a8`, `c79bafa`
- **Abschlussarbeiten (Entwickleranleitung, dieser Vorgang):** Commit `97a6836`
- **Tasks:** 3 (laut PLAN.md), ueber mehrere Sitzungen in zwei Teillieferungen ausgefuehrt
- **Dateien geaendert (gesamter Vorgang):** 110 (biome.json, docs, 2 i18n-Kataloge, ~106 Quell-
und Testdateien)
- **Commits gesamt:** 13
## Vorher/Nachher
| Durchgang | gesamt | echter Quelltext | Testdateien | Fehlerstufe |
|---|---|---|---|---|
| Start | 2923 | 856 | 2067 | 0 |
| Aufgabe 1 — Konfiguration | 754 | 633 | 121 | 0 |
| Aufgabe 2 — maschinell + toter Code | 621 | 542 | 79 | 0 |
| Aufgabe 3A — a11y (Symbole, Schaltflaechen, Layout) | 497 | 418 | 79 | 0 |
| **Aufgabe 3B — a11y (Rollen, Semantik, Beschriftungen) — Endstand** | **465** | **386** | **79** | **0** |
Der Zielwert im PLAN.md-Text lautete 466; der korrekte Wert ist 465, weil Aufgabe 2 beim
Entfernen einer unbenutzten `catch (e: any)`-Bindung in `calendar.service.ts` inzidentell einen
zusaetzlichen `noExplicitAny`-Befund mitgenommen hat (288 statt 289 in echtem Quelltext, 0 in
Testdateien — die Testdatei-Ausnahme aus Aufgabe 1 reicht weiterhin nachweislich nicht in
Produktivcode hinein). Das ist keine nachtraeglich "passend gemachte" Zahl, sondern die
gemessene Tatsache; im Endstand wurde nichts unternommen, um wieder auf 466 zu kommen.
## Aufgabe 1: Konfiguration bereinigen
`biome.json` traegt seither genau zwei `overrides`-Eintraege, sonst nichts an der Regelmenge
geaendert; `security` kommt in der Datei nicht vor (erbt weiterhin `error`).
1. **Testdateien** (`**/*.spec.ts`, `**/*.spec.tsx`, `**/*.test.ts`, `**/*.test.tsx`):
`suspicious/noExplicitAny` auf `off`. Begruendung: `any` haengt dort ausnahmslos an
Attrappen; geprueft mit `grep -rln "^export" --include=*.spec.ts ...` — keine Testdatei
exportiert ein Symbol, keine Nicht-Testdatei fuehrt eine Testdatei ein.
2. **`apps/api/**`**: `style/useImportType` auf `off`. Das ist die wichtigste Einzelentscheidung
des gesamten Vorgangs — siehe naechster Abschnitt.
3. Die fehlerhafte Fixture-Ausnahme (`__fixtures__**` statt `__fixtures__`) korrigiert; Biome
meldte das selbst als `lint/suspicious/useBiomeIgnoreFolder`.
### Der useImportType/NestJS-Befund — der wichtigste Einzelfund dieses Vorgangs
`style/useImportType` ist mit 224 Befunden die groesste Einzelregel, 222 davon in `apps/api`.
Biome stuft die Korrektur als „sicher" ein und wendet sie standardmaessig ohne `--unsafe` an.
Sie ist es in diesem Projekt aber nicht: `apps/api/tsconfig.json` hat `emitDecoratorMetadata`
eingeschaltet, und NestJS loest seine Konstruktor-Abhaengigkeiten zur Laufzeit ueber genau die
Daten auf, die der TypeScript-Compiler aus den Parametertypen erzeugt
(`__metadata("design:paramtypes", [...])`). `import type` markiert einen Import als reinen
Typ-Import; der Compiler entfernt ihn vollstaendig aus dem erzeugten JavaScript — inklusive
der Werte, die `__metadata` braucht. Probelauf an `auth.service.ts` beim Planen: vorher
`__metadata("design:paramtypes", [prisma_service_1.PrismaService, ...])`, nachher
`__metadata("design:paramtypes", [Function, Function, Function, Function, Function, Function])`,
und die zugehoerige `require`-Zeile verschwindet ersatzlos. Ueber den ganzen Workspace gemessen:
**61 von 65 Dateien mit Abhaengigkeitsdaten waeren beschaedigt worden.** Die API startet danach
nicht mehr — jeder DI-Aufloesungsversuch traefe auf `Function` statt auf die echte Klasse.
Der entscheidende Punkt: **kein Tor dieses Projekts haette das bemerkt.** `pnpm type-check`
bleibt gruen (TypeScript prueft Typen, nicht erzeugte Laufzeit-Metadaten). Und kein einziger
der 1124 API-Tests waere rot geworden, denn `grep -rl createTestingModule apps/api/src` liefert
0 Treffer — der gesamte Testbestand startet den NestJS-Dependency-Injection-Container nirgends,
er testet Services/Controller mit von Hand konstruierten Abhaengigkeiten. Ein automatischer,
"sicherer" Fix haette die Anwendung im Betrieb lautlos zerstoert, waehrend jede automatisierte
Pruefung dieses Projekts weiterhin gruen gemeldet haette.
Biome raeumt das Problem in der eigenen Regelbeschreibung ein
(`biome explain useImportType` → Abschnitt „Caveat with TypeScript experimental decorators")
und empfiehlt dort woertlich, die Regel bei solchen Dekoratoren abzuschalten. Aufgabe 1 folgt
dieser Empfehlung mit einem auf `apps/api/**` begrenzten Eintrag — eine bewusste Abweichung von
der generellen mechanischen-Regeln-Vorgabe (D-02), die ausschliesslich deshalb geschieht, weil
"kein Verhaltenswechsel" (D-07) hier Vorrang vor einer kleineren Zahl hat.
**Nachweis, nicht Vermutung:** Vor dem maschinellen Durchgang (Aufgabe 2) wurde ein Fingerabdruck
ueber alle erzeugten `__metadata`-Zeilen genommen (593 Zeilen, sha256
`6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300`). Nach Abschluss aller drei
Aufgaben — Konfiguration, maschineller Durchgang, Barrierefreiheit — ist dieser Fingerabdruck
**Zeichen fuer Zeichen identisch**, gerade jetzt erneut gemessen:
```
$ SNAP=$(mktemp -d); pnpm --filter @tessera/api exec tsc --outDir "$SNAP"
$ grep -rh '__metadata(' "$SNAP" | wc -l
593
$ grep -rh '__metadata(' "$SNAP" | sort | sha256sum
6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300 -
```
Das ist der tragende Nachweis dieses gesamten Vorgangs — nicht der gruene Testlauf, der diesen
spezifischen Schaden nachweislich nicht aufdecken kann.
## Aufgabe 2: Maschinelle Korrekturen und toter Code
**(A) Vier Regeln mit gesichertem Fix** (`--write` ohne `--unsafe`): `style/useImportType`
(pfadgebunden auf `apps/web packages`, NIE repo-weit — ein repo-weiter Lauf haette die
Ausnahme aus Aufgabe 1 wirkungslos ausgehaengt und 201 `import type`-Zeilen nach `apps/api`
geschrieben, beim Planen genau so gemessen), `complexity/noUselessEscapeInRegex`,
`style/useConst`, `style/useExponentiationOperator`.
**(B) Fuenf Regeln mit `--unsafe`-Fix, Diff vollstaendig von Hand gelesen** (~100 Zeilen):
`style/useNodejsImportProtocol`, `complexity/useLiteralKeys`, `complexity/useOptionalChain`,
`style/useTemplate`, `correctness/useParseIntRadix`. Zwei Dateien brauchten besondere
Aufmerksamkeit:
- `ldap.service.ts` (25 der 31 `useLiteralKeys`-Aenderungen): Verzeichnis-Merkmale
(`objectGUID`, `sAMAccountName`, `cn`, `ou`, `mail`) zeichenweise gegengelesen — ein
verschluckter Grossbuchstabe macht den AD-Abgleich still leer, und AD ist in diesem Projekt
bewusst nur lesend angebunden, faellt also erst beim Anmelden auf.
- `jwt.strategy.ts` / `auth.service.ts`: Verkuerzungen im Anmeldeweg (`useOptionalChain`)
geprueft, dass eine fehlende Sitzung weiterhin zur Abweisung fuehrt, nicht zum Durchwinken.
**Die sechste ungesicherte Regel, `complexity/noUselessSwitchCase` (1 Fundstelle,
`tender-normalizer.service.ts:60`), wurde bewusst NICHT angewendet.** Der Vorschlag wuerde
eine Fallmarke streichen, die unmittelbar ueber einem Kommentar steht, der erklaert, warum der
Standardzweig genau auf diesem Weg bleiben muss. Die Marke dokumentiert Absicht, die der Regel
entgeht. Sie steht weiterhin sichtbar in der Zaehlung (1), nicht unterdrueckt — eine
Unterdrueckung waere hier unehrlicher als das sichtbare Stehenlassen, weil sie wie eine
Erledigung aussehen wuerde, ohne eine zu sein.
**(C) Toter Code, 15 Fundstellen, drei Gruppen:**
1. *Echt tot, folgenlos entfernt (10):* nicht benutzte Fehlervariablen in `calendar.service.ts`
(Zeilen 321, 358), `cert-manager.service.ts` (305, 679), `dkv-parser.service.ts` (42); nicht
benutzte Einfuhren in `create-calendar-source.dto.ts:2`; nicht benutzte Funktion
`forSystemQuery` in `rls-scratch-check.mjs:226`; und `login/page.tsx:21` (unbenutzter
Wegweiser — Weiterleitung ist nachweislich anderswo geloest, daher entfernt ohne Meldung).
2. *Nicht entfernbar, umbenannt (1):* `current-user.decorator.ts:4` — `data` ist der erste von
zwei positionsgebundenen NestJS-Parametern; Streichen wuerde den zweiten verschieben.
Stattdessen mit fuehrendem Unterstrich gekennzeichnet.
3. *Symptome, gemeldet statt repariert (4 — siehe naechster Abschnitt).*
### Die vier gemeldeten Symptomfunde (D-03) — offene Folgeaufgaben
Diese vier unbenutzten Werte waren beim Lesen KEIN totes Code-Rauschen, sondern der Hinweis auf
eine echte Luecke. Sie zu schliessen waere ein Verhaltenswechsel gewesen, den dieser
lint-abbauende Vorgang nicht treffen durfte (D-07):
1. **`force-password-change.interceptor.ts:53`** liest das HTTP-Verfahren in eine Variable und
befragt sie nie — die Freigabeliste unterscheidet also nicht zwischen Lese- und
Schreibzugriff auf die freigegebenen Wege. Variable entfernt, Luecke hier als eigene
Folgeaufgabe benannt.
2. **`change-password/page.tsx:11-12`** hielt Wegweiser und Benutzerablage vor, benutzte beide
nicht. Bestaetigt beim Lesen: nach erfolgreichem Wechsel wird weder weitergeleitet noch die
Benutzerablage aufgefrischt — bei erzwungenem Wechsel bleibt die Person auf der Seite
stehen. Die drei Bindungen entfernt, Befund hier als Folgeaufgabe benannt.
3. **`VehicleTable.tsx:164`** setzte einen Laufzustand fuers Loeschen, las ihn aber nie — die
Loeschschaltflaeche hat also keinen Besetztzustand und laesst sich doppelt ausloesen. Nur
die lesende Bindung entfernt, die setzende blieb; Befund hier als Folgeaufgabe benannt.
4. **`SplitTab.tsx:20`** bekam die Uebersetzungsfunktion und benutzte sie nicht — ein Hinweis
auf fest verdrahtete Texte in diesem Reiter. Parameter entfernt, Befund hier als
Folgeaufgabe benannt.
(Ein fuenfter untersuchter Kandidat, `login/page.tsx:21`, gehoerte urspruenglich zur selben
"Symptom"-Kategorie in der Planung, stellte sich beim Lesen aber als echt folgenlos heraus —
siehe Gruppe 1 oben. Er wird hier zur Vollstaendigkeit genannt, braucht aber keine eigene
Folgeaufgabe.)
**Nachweis fuer den gesamten maschinellen Durchgang:** 45 Quelldateien geaendert, Diff
zeilenbilanziert (keine Formatierung mitgelaufen, D-08), NestJS-Metadaten-Fingerabdruck
unveraendert (siehe oben), beide Testlaeufe punktgleich gruen.
## Aufgabe 3: Barrierefreiheit von Hand (beide Teillieferungen)
155 Fundstellen in 53 Dateien, durchgehend Handarbeit — fuer alle sechs bearbeiteten Regeln bot
Biome weder einen gesicherten noch ungesicherten Fix (einzige Ausnahme: `noRedundantRoles` mit 3
Dateien unter `--unsafe`, Ergebnis trotzdem gelesen).
### Teillieferung A (vorherige Sitzung, Commits `e76f3b8` … `73ac08a`)
- `a11y/noSvgWithoutTitle` (71): je Symbol entschieden — begleitet es sichtbaren Text, wird es
als Schmuck vor der Vorlesehilfe verborgen; steht es allein, bekommt es einen Titel, der die
Bedienung nennt (Uebersetzungskatalog, wo die Datei schon uebersetzt ist). Zwei Fundstellen
ausserhalb der React-Oberflaeche: `apps/web/src/app/icon.svg` (Bildmarke, Titel mit
Produktnamen) und `apps/desktop/src/setup.html:173` (Desktop-Einrichtungsseite).
- `a11y/useButtonType` (52): nur 3 von 25 betroffenen Dateien enthalten ein Formular
(`admin/tenants/page.tsx`, `admin/users/page.tsx`, `admin/ldap/page.tsx`); dort war der
Absendeknopf je bereits richtig ausgezeichnet (1/1/2), die restlichen 16 Fundstellen waren
Neben-Schaltflaechen (Abbrechen, Schliessen, Zeilenaktionen), die beim Klick ungewollt
absendeten — bekamen `type="button"`. In den uebrigen 22 Dateien ohne Formular ist die
Auszeichnung reine Absicherung.
- Zwei neue Uebersetzungsschluessel (LDAP-Standardzuordnung, Suchbutton), in `de.json` UND
`en.json` ergaenzt (Commit `3811578`).
### Teillieferung B (diese Sitzung, Commits `21c85a8`, `c79bafa`)
Die verbleibenden fuenf zugewiesenen Regeln (32 Fundstellen) auf 0 gebracht:
- **`a11y/noRedundantRoles` (4):** maschineller `--unsafe`-Fix, gelesen. Entfernte
`role="button"`/`role="time"`/`role="combobox"` von `button`/`time`/`select`-Elementen, wo
die Rolle bereits implizit ist.
- **`a11y/useAriaPropsForRole` (1):** entfiel automatisch mit obigem Fix — das
`<select role="combobox">` in `search-widget.tsx` verlangte die fehlenden ARIA-Attribute nur
wegen der ueberfluessigen Rolle.
- **`a11y/useSemanticElements` (4):** `admin-sidebar.tsx`/`settings-sidebar.tsx` tragen
`role="navigation"` jetzt am bereits vorhandenen `<nav>` statt am `<aside>` (kein doppeltes
Landmark); `widget-wrapper.tsx` ist jetzt ein echtes `<article>` statt `div role="article"`;
`DropZone.tsx` trennt die "Entfernen"-Schaltflaeche als Geschwister ab, damit die Drop-Flaeche
selbst ein echtes `<button>` werden kann (ein `<button>` darf kein zweites `<button>`
verschachteln) — Klick- UND Drag-Handler wanderten dabei auf den `<button>`, sonst waere die
umgebende `<div>` ein "statisches Element mit Ereignis-Handler" geworden und haette die
zurueckgestellten Regeln `noStaticElementInteractions`/`noNoninteractiveElementInteractions`
neu ausgeloest (geprueft — geschah zunaechst versehentlich, wurde vor dem Commit korrigiert).
- **`a11y/noNoninteractiveTabindex` (1):** `calculator-widget.tsx` traegt jetzt `tabIndex={-1}`
statt `{0}`. Die Zifferntasten sind bereits echte `<button>`-Elemente und damit selbst Teil
der Tab-Reihenfolge; Tastendruecke erreichen `handleKeyboard` weiterhin per Bubbling, sobald
eine Taste fokussiert ist — Verhalten unveraendert, nur ein wirkungsloser Tab-Stopp auf dem
Container selbst entfaellt.
- **`a11y/noLabelWithoutControl` (22):** jede Beschriftung ueber `htmlFor`/`id` an ihr Feld
gebunden — in Formularen mit wiederholten Feldnamen (LDAP, Benutzer, Mandanten) ueber
seitenweit eindeutige, praefixierte Kennungen (`ldap-*`, `user-*`, `tenant-*`). Sonderfall
`calendar-source-form.tsx`: die Farbauswahl beschriftet eine ganze Gruppe von
Farb-Schaltflaechen, kein einzelnes Feld — dafuer `fieldset`/`legend` statt `htmlFor`/`id`
(Rand/Abstand zurueckgesetzt, damit sich am Erscheinungsbild nichts aendert); eine Umwandlung
in `<span>` haette die Assoziation entfernt statt sie herzustellen und wurde darum nicht
gewaehlt.
### Was bewusst stehen bleibt (30 Befunde, D-05)
Diese fuenf Regeln wurden NICHT bearbeitet, weil jede eine Gestaltungsentscheidung oder einen
Verhaltenswechsel verlangt, den dieser Vorgang nicht treffen darf. Gepruefte, unveraenderte
Zaehlung nach Teillieferung B:
| Regel | Befunde | Warum zurueckgestellt |
|---|---|---|
| `a11y/noNoninteractiveElementInteractions` | 11 | verlangt die Entscheidung, ob ein geklickter Bereich eine echte Bedienung wird oder der Klick verschwindet |
| `a11y/noStaticElementInteractions` | 5 | dieselbe Entscheidung, andere Fundstellenmenge |
| `a11y/useKeyWithClickEvents` | 5 | verlangt einen Tastaturweg, den es heute nicht gibt — neue Bedienung, kein Aufraeumen |
| `a11y/useAriaPropsSupportedByRole` | 5 | verlangt einen Blick auf jede gesetzte Rolle einzeln, teils mit Gestaltungsfolgen |
| `a11y/noAutofocus` | 4 | Entfernen verschiebt den Eingabefokus beim Seitenaufruf — Verhaltenswechsel (D-07 verbietet ihn hier) |
Keine dieser Regeln wurde herabgestuft oder abgeschaltet — sie stehen weiterhin auf `warn` und
tauchen in keiner `overrides`-Ausnahme auf.
## Verifikation (soeben erneut ausgefuehrt)
```
--- in-scope (Ziel 0) ---
noLabelWithoutControl 0
noRedundantRoles 0
useSemanticElements 0
useAriaPropsForRole 0
noNoninteractiveTabindex 0
--- zurueckgestellt (muss unveraendert bleiben) ---
noNoninteractiveElementInteractions 11
useKeyWithClickEvents 5
noStaticElementInteractions 5
useAriaPropsSupportedByRole 5
noAutofocus 4
Endstand: total 465 real 386 test 79 errors 0
```
- **`apps/api` und `packages` unberuehrt seit `636fe0d`:** `git diff --name-only 636fe0d..HEAD -- apps/api packages` → leer.
- **NestJS-`__metadata`-Fingerabdruck:** 593 Zeilen, sha256
`6e1583f1eb72a089eb0ed98f81158b54a9fbd40dbf41371292725f36ef764300` — identisch zum Ausgangswert.
- **`apps/web` Vitest:** `Test Files 66 passed (66)`, `Tests 459 passed (459)`.
- **`apps/api` Vitest:** `Test Files 69 passed (69)`, `Tests 1124 passed (1124)`.
- **`pnpm type-check`:** 4/4 erfolgreich.
- **`pnpm lint --force`:** 5/5 erfolgreich, 0 Befunde der Stufe Fehler (nur Warnungen/Info).
- **de/en-Uebersetzungskataloge:** 892 Schluessel je Katalog, Mengen identisch (0 nur-de, 0 nur-en).
- **`noExplicitAny` nach der Testdatei-Ausnahme:** 288 in echtem Quelltext (siehe Abschnitt
"Vorher/Nachher" fuer die 289→288-Abweichung), 0 in Testdateien.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Handler-Typannotation in DropZone.tsx nach Restrukturierung**
- **Found during:** Teillieferung B, `useSemanticElements`-Fix an `DropZone.tsx`
- **Issue:** Nach dem Verschieben der Drag-Handler von der `<div>` auf die `<button>`
blieb die Typannotation `React.DragEvent<HTMLDivElement>` stehen; `pnpm type-check` schlug
mit zwei `TS2322`-Fehlern fehl.
- **Fix:** Annotation auf `React.DragEvent<HTMLButtonElement>` korrigiert.
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx`
- **Verification:** `pnpm type-check` danach 4/4.
- **Committed in:** `21c85a8`
**2. [Rule 1 - Bug, waehrend der Arbeit selbst erkannt und korrigiert] Neu ausgeloeste
zurueckgestellte Regeln in DropZone.tsx**
- **Found during:** Teillieferung B, unmittelbar nach dem ersten Entwurf des
`useSemanticElements`-Fixes an `DropZone.tsx`
- **Issue:** Das blosse Entfernen von `role="button"`/`tabIndex`/`onClick` von der Flaechen-
`<div>` (um die Verschachtelung von zwei `<button>`-Elementen aufzuloesen) liess die
verbliebenen `onDragOver`/`onDragLeave`/`onDrop`-Handler auf einer jetzt rollenlosen `<div>`
stehen — das loeste `noStaticElementInteractions` und `noNoninteractiveElementInteractions`
NEU aus, zwei Regeln, die laut Auftrag bei 5 bzw. 11 unveraendert bleiben mussten.
- **Fix:** Struktur korrigiert: die Drop-Flaeche selbst wurde zum `<button>` (traegt jetzt
Klick- UND Drag-Handler), die "Entfernen"-Schaltflaeche liegt als absolut positioniertes
Geschwister in der Ecke statt verschachtelt.
- **Files modified:** dieselbe Datei wie oben.
- **Verification:** Volle Regelmessung nach dem Fix zeigt `noStaticElementInteractions=5`,
`noNoninteractiveElementInteractions=11` — unveraendert zum Ausgangswert.
- **Committed in:** `21c85a8` (im selben Commit korrigiert, nie mit dem Fehler committet)
---
**Total deviations:** 2 auto-fixed (1 blocking type error, 1 selbst erkannte und vor dem
Commit korrigierte Regelkollision). Keine der beiden Abweichungen hat den Endstand beeinflusst
— beide wurden vor dem jeweiligen Commit vollstaendig geloest.
### Vom Plantext abweichende Zuordnung
**[Scope-Abweichung] `a11y/useSemanticElements` (4 Fundstellen) in Teillieferung B bearbeitet,
obwohl PLAN.md diese Regel unter D-05 als "erst mit Gestaltungsentscheidung" zurueckgestellt
hatte.** Der Auftrag fuer diese Sitzung hat die Regel explizit in den 32er-Umfang von
Teillieferung B aufgenommen (Zielwert 465 rechnet sie mit ein: 497 − 32 = 465). Bearbeitet wie
oben beschrieben — in allen vier Faellen ohne Verhaltenswechsel, nur Tag-/Attributverschiebung.
Dokumentiert hier, weil PLAN.md selbst etwas anderes vorsah.
## Issues Encountered
Keine ungeloesten Probleme. Die einzige echte Schwierigkeit — die gegenseitige Verschiebung von
a11y-Klassifikationen zwischen bearbeiteten und zurueckgestellten Regeln bei `DropZone.tsx` — ist
unter Deviations dokumentiert und vor dem Commit geloest worden.
## Known Stubs
Keine. Alle Aenderungen sind vollstaendige, funktionierende Korrekturen; keine Platzhalter,
keine leeren Datenquellen.
## User Setup Required
None — keine externe Konfiguration erforderlich.
## Next Phase Readiness
Der Lint-Rueckstand ist von 2923 auf 465 gesunken und vollstaendig benannt: fuenf
zurueckgestellte a11y-Regeln (30 Befunde, D-05), eine bewusst nicht angewendete Regel
(`noUselessSwitchCase`, 1 Befund) und vier gemeldete D-03-Symptomfunde. Empfohlene Folgeaufgaben
fuer einen spaeteren Vorgang, in absteigender Dringlichkeit:
1. `force-password-change.interceptor.ts` — HTTP-Verfahren tatsaechlich pruefen, sonst
unterscheidet die Freigabeliste nicht zwischen Lese- und Schreibzugriff.
2. `change-password/page.tsx` — nach erzwungenem Wechsel weiterleiten und Benutzerablage
auffrischen.
3. `VehicleTable.tsx` — Besetztzustand der Loeschschaltflaeche tatsaechlich anzeigen.
4. `SplitTab.tsx` — fest verdrahtete Texte durch den Uebersetzungskatalog ersetzen.
5. Die fuenf zurueckgestellten a11y-Regeln (30 Befunde) als eigener, mit UI/Design
abgestimmter Durchgang.
Kein Blocker fuer laufenden Betrieb: `pnpm lint --force` bleibt gruen, CI-Tor unveraendert scharf.
## Self-Check: PASSED
Alle referenzierten Dateien (`biome.json`, `docs/anleitung-entwicklung.md`, diese Summary,
`DropZone.tsx`, `calendar-source-form.tsx`) auf Datenträger gefunden. Alle referenzierten
Commit-Hashes (`8d1c8f3`, `3811578`, `636fe0d`, `e76f3b8`, `4cff316`, `ae82125`, `278aedb`,
`969fd01`, `27a6e29`, `73ac08a`, `21c85a8`, `c79bafa`, `97a6836`) im Verlauf gefunden.
---
*Vorgang: quick-260921-bi2*
*Abgeschlossen: 2026-09-21*
File diff suppressed because one or more lines are too long
+1 -14
View File
@@ -216,19 +216,6 @@ async function forTenantQuery(prisma, tenantId, queryFn, userId) {
return result;
}
/**
* Spiegelbildlich zu `forSystem()` in apps/api/src/prisma/prisma-tenant.extension.ts
* (Etappe 3c, 260914-eym) — bei jeder Aenderung dort HIER nachziehen: EINE
* getaggte Anweisung setzt `app.system_context = 'true'` und AUSDRUECKLICH
* `app.current_tenant = ''` und `app.current_user = ''`, alle drei als
* Literale; danach die Abfrage in derselben Array-Transaktion.
*/
async function forSystemQuery(prisma, queryFn) {
const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
const [, result] = await prisma.$transaction([setContext, queryFn(prisma)]);
return result;
}
/**
* Aufgabe 1 — misst die fuenf im Plan genannten Verhaltensweisen von
* forTenant() unter der Rolle ohne BYPASSRLS.
@@ -3242,7 +3229,7 @@ async function runCalendarAreaChecks(adminUrl, scratchRoleUrl, results) {
// gebunden MIT Benutzer jetzt NICHT mehr lesbar ist (siehe Umkehrung
// unten).
const a2Row = rowsForA.find((r) => r.userId === 'user-a2');
const a2CredentialsVisible = Boolean(a2Row && a2Row.encryptedPassword);
const a2CredentialsVisible = Boolean(a2Row?.encryptedPassword);
report(
results,
'calendarsource-ohne-benutzer-sieht-beide-nutzer-desselben-mandanten',
+5 -5
View File
@@ -9,7 +9,7 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Role } from '@prisma/client';
import * as argon2 from 'argon2';
import { randomUUID } from 'crypto';
import { randomUUID } from 'node:crypto';
import { Response } from 'express';
import { LdapConfigService } from '../ldap/ldap-config.service';
import { LdapService } from '../ldap/ldap.service';
@@ -111,7 +111,7 @@ export class AuthService {
`;
const user = rows[0];
if (!user || !user.isActive) {
if (!user?.isActive) {
return null;
}
@@ -125,7 +125,7 @@ export class AuthService {
}
const config = await this.ldapConfigService.getConfig(user.tenantId);
if (!config || !config.isActive) {
if (!config?.isActive) {
return null;
}
@@ -220,7 +220,7 @@ export class AuthService {
const user = rows[0];
// Always return success to prevent email enumeration (T-02-12)
if (!user || !user.isActive) {
if (!user?.isActive) {
this.logger.log(
`Password reset requested for unknown/inactive email: ${email}`,
);
@@ -358,7 +358,7 @@ export class AuthService {
where: { id: userId },
});
if (!user || !user.passwordHash) {
if (!user?.passwordHash) {
throw new UnauthorizedException('User not found or has no local password');
}
@@ -1,7 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
(_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
@@ -50,7 +50,6 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
// Allow specific routes even when password change is required
const path = request.route?.path || request.url;
const method = request.method;
const allowedPaths = [
'/auth/change-password',
+2 -2
View File
@@ -8,8 +8,8 @@ import { Request } from 'express';
* Custom extractor that reads JWT from the httpOnly "session" cookie.
*/
function cookieExtractor(req: Request): string | null {
if (req && req.cookies) {
return req.cookies['session'] || null;
if (req?.cookies) {
return req.cookies.session || null;
}
return null;
}
+2 -2
View File
@@ -318,7 +318,7 @@ export class CalendarService {
});
return { success };
} catch (error) {
} catch {
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
await tenantPrisma.calendarSource.update({
where: { id },
@@ -355,7 +355,7 @@ export class CalendarService {
try {
const success = await provider.testConnection(tempSource);
return { success };
} catch (e: any) {
} catch {
return { success: false, error: 'Connection failed' };
}
}
@@ -1,5 +1,4 @@
import {
IsBoolean,
IsHexColor,
IsIn,
IsNotEmpty,
@@ -47,7 +47,7 @@ export class CalDAVProvider implements CalendarProvider {
for (const key of Object.keys(parsed)) {
const component = parsed[key];
if (!component || component.type !== 'VEVENT') continue;
if (component?.type !== 'VEVENT') continue;
const vevent = component as ical.VEvent;
const start = new Date(vevent.start);
@@ -180,8 +180,8 @@ export class ExchangeProvider implements CalendarProvider {
id: `${source.id}-${item.id}`,
sourceId: source.id,
title: item.subject || 'Untitled',
start: new Date(item.start?.dateTime + 'Z'),
end: new Date(item.end?.dateTime + 'Z'),
start: new Date(`${item.start?.dateTime}Z`),
end: new Date(`${item.end?.dateTime}Z`),
allDay: item.isAllDay || false,
location: item.location?.displayName || undefined,
description: item.bodyPreview || undefined,
@@ -30,7 +30,7 @@ export class ICSProvider implements CalendarProvider {
for (const key of Object.keys(data)) {
const component = data[key];
if (!component || component.type !== 'VEVENT') continue;
if (component?.type !== 'VEVENT') continue;
const vevent = component as ical.VEvent;
@@ -116,7 +116,7 @@ describe('CertManagerService helpers', () => {
const cert2 = generateSelfSignedCert();
const pem1 = forge.pki.certificateToPem(cert1);
const pem2 = forge.pki.certificateToPem(cert2);
const chain = pem1 + '\n' + pem2;
const chain = `${pem1}\n${pem2}`;
const parsed = service.parsePemChain(chain);
expect(parsed).toHaveLength(2);
@@ -266,7 +266,7 @@ describe('splitCerts', () => {
cert2Pem = forge.pki.certificateToPem(c2);
// Fullchain fixture: two PEMs concatenated
const fullchainPem = cert1Pem + '\n' + cert2Pem;
const fullchainPem = `${cert1Pem}\n${cert2Pem}`;
fullchainBuffer = Buffer.from(fullchainPem, 'utf-8');
// P7B fixture: PEM-wrapped PKCS7 SignedData bundle with both certs
@@ -302,7 +302,7 @@ export class CertManagerService {
fingerprint: { sha1, sha256 },
pemPreview: forge.pki.certificateToPem(cert),
};
} catch (err) {
} catch {
this.logger.warn('parseCert: failed to extract CertDetails fields');
throw new BadRequestException('Failed to extract certificate details');
}
@@ -505,7 +505,7 @@ export class CertManagerService {
return {
filename: 'chain.pem',
content,
mimeType: FORMAT_MIME['pem'],
mimeType: FORMAT_MIME.pem,
};
} else if (outputFormat === 'pfx') {
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
@@ -523,7 +523,7 @@ export class CertManagerService {
return {
filename: 'bundle.pfx',
content,
mimeType: FORMAT_MIME['pfx'],
mimeType: FORMAT_MIME.pfx,
};
} else {
throw new BadRequestException(
@@ -667,7 +667,7 @@ export class CertManagerService {
return {
filename: 'converted.pfx',
content,
mimeType: FORMAT_MIME['pfx'],
mimeType: FORMAT_MIME.pfx,
};
}
@@ -676,7 +676,7 @@ export class CertManagerService {
content,
mimeType: FORMAT_MIME[targetFormat],
};
} catch (err) {
} catch {
this.logger.warn('convertCert: failed to serialize to target format');
throw new BadRequestException(
`Failed to convert certificate to ${targetFormat}: serialization error`,
+1 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/** Current name of the platform-wide encryption key. */
export const ENCRYPTION_KEY_ENV = 'TESSERA_ENCRYPTION_KEY';
+5 -5
View File
@@ -1,8 +1,8 @@
import 'reflect-metadata';
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { NestFactory } from '@nestjs/core';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@@ -168,7 +168,7 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
});
it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => {
const missingDir = path.join(os.tmpdir(), 'tessera-desktop-does-not-exist-' + Date.now());
const missingDir = path.join(os.tmpdir(), `tessera-desktop-does-not-exist-${Date.now()}`);
const previous = process.env.DESKTOP_DIST_DIR;
process.env.DESKTOP_DIST_DIR = missingDir;
try {
+2 -2
View File
@@ -6,8 +6,8 @@ import type {
DesktopPlatform,
DesktopUpdateResponse,
} from '@tessera/shared';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
/**
* Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte
+2 -2
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as XLSX from 'xlsx';
import { ExportRow } from './dkv.types';
+2 -2
View File
@@ -39,7 +39,7 @@ export class DkvParserService {
let text: string;
try {
text = await this.extractText(buffer);
} catch (err) {
} catch {
this.logger.error('DKV PDF text extraction failed');
throw new Error('DKV invoice parsing failed — text extraction error');
}
@@ -98,7 +98,7 @@ export class DkvParserService {
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end
// Kennzeichen format: "GP-JL 728E", "GP ML 720", etc.
const vehicleBlockPattern =
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
/VEHICLE:\s+([A-Z0-9 ._-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
let match: RegExpExecArray | null;
while ((match = vehicleBlockPattern.exec(text)) !== null) {
+1 -1
View File
@@ -133,7 +133,7 @@ function parseDkvText(text: string): DkvVehicleBlock[] {
// Anchor on VEHICLE: marker — each block extends until next VEHICLE: or end of text
// Kennzeichen can contain letters, digits, hyphens, spaces, dots (e.g. "GP-JL 728E")
const vehicleBlockPattern =
/VEHICLE:\s+([A-Z0-9 ._\-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
/VEHICLE:\s+([A-Z0-9 ._-]+?)\s+CARD NO\.:\s+(\S+)([\s\S]*?)(?=VEHICLE:|$)/g;
let match: RegExpExecArray | null;
while ((match = vehicleBlockPattern.exec(text)) !== null) {
+1 -1
View File
@@ -219,7 +219,7 @@ export class DkvController {
) {
const tenantId = this._requireTenant(req);
if (!file || !file.buffer) {
if (!file?.buffer) {
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
}
+1 -1
View File
@@ -1,4 +1,4 @@
import * as fs from 'fs';
import * as fs from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DkvService } from './dkv.service';
+7 -7
View File
@@ -5,8 +5,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { CryptoService } from '../crypto/crypto.service';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
@@ -513,7 +513,7 @@ export class DkvService {
smtpError = (err as Error).message;
this.logger.warn(`DKV SMTP send attempt ${attempt}/3 failed: ${smtpError}`);
if (attempt < 3) {
await _delay(Math.pow(2, attempt) * 1000); // 2s, 4s
await _delay(2 ** attempt * 1000); // 2s, 4s
} else {
smtpStatus = 'Versand fehlgeschlagen';
// D-16: file stays locally available for manual download
@@ -707,7 +707,7 @@ export class DkvService {
filename.includes('/') ||
filename.includes('\\') ||
filename.includes('..') ||
!/^(RG-DKV-|DKV_)[\w\-]+\.xlsx$/.test(filename)
!/^(RG-DKV-|DKV_)[\w-]+\.xlsx$/.test(filename)
) {
throw new BadRequestException('Invalid export filename');
}
@@ -806,9 +806,9 @@ export class DkvService {
): string {
if (fromPdf) return fromPdf.replace(/\//g, '-');
// DKV email subjects carry the invoice number with slashes: "26/650869002/002"
const m = subject?.match(/(\d{2}[\/\-]\d{9}[\/\-]\d{3})/);
const m = subject?.match(/(\d{2}[/-]\d{9}[/-]\d{3})/);
if (m?.[1]) return m[1].replace(/\//g, '-');
return `email-${String(uid).replace(/[^a-zA-Z0-9\-]/g, '_')}`;
return `email-${String(uid).replace(/[^a-zA-Z0-9-]/g, '_')}`;
}
/**
@@ -880,5 +880,5 @@ function _formatDateYYMMDD(date: string): string {
* Example: "GP JL 740E" == "GP-JL 740E" == "GPJL740E" after normalization.
*/
function _normalizeKennzeichen(k: string): string {
return k.toUpperCase().replace(/[\s\-\.]/g, '');
return k.toUpperCase().replace(/[\s\-.]/g, '');
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
/**
+1 -1
View File
@@ -1044,7 +1044,7 @@ describe('GroupsService — Bindung an forTenant()/withTenantTransaction() (2609
it('reassignDefaultBeforeDelete() bindet die drei Lesezugriffe UND die Transaktion an denselben Mandanten', async () => {
const prisma = makeFakePrisma();
const service = new GroupsService(prisma as any);
const def = await service.create('t1', { name: DEFAULT_GROUP_NAME });
await service.create('t1', { name: DEFAULT_GROUP_NAME });
const toDelete = await service.create('t1', { name: 'Zu loeschen' });
await service.update('t1', toDelete.id, { isDefault: true });
+1 -1
View File
@@ -440,7 +440,7 @@ export class GroupsService {
const group = await tenantPrisma.group.findFirst({
where: { id: groupId, tenantId },
});
if (!group || !group.isDefault) {
if (!group?.isDefault) {
return false;
}
+1 -1
View File
@@ -20,7 +20,7 @@ vi.mock('imapflow', () => ({
}));
function makeReadable(text: string): NodeJS.ReadableStream {
const { Readable } = require('stream') as typeof import('stream');
const { Readable } = require('node:stream') as typeof import('stream');
return Readable.from([Buffer.from(text, 'utf8')]);
}
@@ -84,7 +84,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
const written = prisma.ldapConfig.create.mock.calls[0][0].data;
expect(written.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
`aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
);
expect(JSON.stringify(written)).not.toContain('geheim');
// Die alte Klartext-Spalte darf nicht wieder auftauchen.
@@ -95,7 +95,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
await service.updateConfig('t1', { bindPassword: 'neu' } as any);
const first = prisma.ldapConfig.update.mock.calls[0][0].data;
expect(first.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('neu').toString('hex'),
`aa11:bb22:${Buffer.from('neu').toString('hex')}`,
);
await service.updateConfig('t1', { serverUrl: 'ldap://anders' } as any);
@@ -113,7 +113,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
it('gibt Aufrufern weiterhin ein entschluesseltes bindPassword', async () => {
prisma.ldapConfig.findUnique.mockResolvedValue({
...CONFIG_ROW,
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('geheim').toString('hex'),
encryptedBindPassword: `aa11:bb22:${Buffer.from('geheim').toString('hex')}`,
});
const config: any = await service.getConfig('t1');
@@ -156,7 +156,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
{
id: 'b',
tenantId: 't2',
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('schon').toString('hex'),
encryptedBindPassword: `aa11:bb22:${Buffer.from('schon').toString('hex')}`,
},
{ id: 'c', tenantId: 't3', encryptedBindPassword: null },
]);
@@ -167,7 +167,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
const call = prisma.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'a' });
expect(call.data.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
);
});
@@ -176,7 +176,7 @@ describe('LdapConfigService — Bind-Passwort verschluesselt at rest', () => {
{
id: 'a',
tenantId: 't1',
encryptedBindPassword: 'aa11:bb22:' + Buffer.from('x').toString('hex'),
encryptedBindPassword: `aa11:bb22:${Buffer.from('x').toString('hex')}`,
},
]);
@@ -354,7 +354,7 @@ describe('LdapConfigService — Bindung an forTenant() (260909-ipc)', () => {
const call = boundClient.ldapConfig.update.mock.calls[0][0];
expect(call.where).toEqual({ id: 'alt' });
expect(call.data.encryptedBindPassword).toBe(
'aa11:bb22:' + Buffer.from('klartext').toString('hex'),
`aa11:bb22:${Buffer.from('klartext').toString('hex')}`,
);
// Der rohe Client schreibt NICHT.
expect(prisma.ldapConfig.update).not.toHaveBeenCalled();
+2 -2
View File
@@ -1545,7 +1545,7 @@ describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verz
isDefault: false,
},
];
prisma.group.update = vi.fn((args: any) => {
prisma.group.update = vi.fn((_args: any) => {
const err: any = new Error('Unique constraint');
err.code = 'P2002';
// A P2002 on the OTHER unique index this update() can hit —
@@ -1793,7 +1793,7 @@ describe('LdapService.syncBoundGroupsForTenant — Rekonziliation gegen das Verz
];
mockSearch.mockImplementation((_baseDn: string, opts: any) => {
const swept = sweptGuid(opts.filter);
if (swept && swept.equals(guidBuffer)) {
if (swept?.equals(guidBuffer)) {
return Promise.reject(new Error('directory unavailable'));
}
return Promise.resolve({
+25 -25
View File
@@ -322,13 +322,13 @@ export class LdapService {
const dn = entry.dn;
const isOu = /^ou=/i.test(dn);
const record = entry as unknown as Record<string, unknown>;
const rawName = isOu ? record['ou'] : record['cn'];
const rawName = isOu ? record.ou : record.cn;
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
? String(rawName)
: dn;
const guidValue = record['objectGUID'];
const guidValue = record.objectGUID;
const guidHex =
!isOu && Buffer.isBuffer(guidValue)
? guidValue.toString('hex')
@@ -401,7 +401,7 @@ export class LdapService {
mappedData[mapping.tesseraField] = String(resolved);
}
}
return { username: mappedData['username']?.toLowerCase(), mappedData };
return { username: mappedData.username?.toLowerCase(), mappedData };
}
/**
@@ -483,13 +483,13 @@ export class LdapService {
if (existing) {
let emailToWrite: string | undefined;
let emailConflict: LdapEmailConflict | undefined;
if (mappedData['email']) {
if (mappedData.email) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
mappedData.email,
existing.id,
);
if (decision.collides) {
emailConflict = { account: username, email: mappedData['email'] };
emailConflict = { account: username, email: mappedData.email };
} else {
emailToWrite = decision.email;
}
@@ -498,11 +498,11 @@ export class LdapService {
await tenantPrisma.user.update({
where: { id: existing.id },
data: {
...(mappedData['displayName'] && {
displayName: mappedData['displayName'],
...(mappedData.displayName && {
displayName: mappedData.displayName,
}),
...(emailToWrite && { email: emailToWrite }),
...(mappedData['username'] && { username }),
...(mappedData.username && { username }),
ldapDn: dn,
isActive: true,
},
@@ -511,16 +511,16 @@ export class LdapService {
}
let createEmail: string | undefined =
mappedData['email'] || `${username}@ldap.local`;
mappedData.email || `${username}@ldap.local`;
let emailConflict: LdapEmailConflict | undefined;
if (mappedData['email']) {
if (mappedData.email) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
mappedData.email,
null,
);
if (decision.collides) {
createEmail = undefined;
emailConflict = { account: username, email: mappedData['email'] };
emailConflict = { account: username, email: mappedData.email };
} else {
createEmail = decision.email;
}
@@ -529,7 +529,7 @@ export class LdapService {
await this.userService.create({
username,
...(createEmail && { email: createEmail }),
displayName: mappedData['displayName'],
displayName: mappedData.displayName,
role: 'USER',
tenantId,
ldapDn: dn,
@@ -592,9 +592,9 @@ export class LdapService {
const entries = Array.from(entriesByDn.values()).map((entry) => ({
dn: entry.dn,
username: first(entry['sAMAccountName']),
displayName: first(entry['displayName']) || first(entry['cn']),
email: first(entry['mail']),
username: first(entry.sAMAccountName),
displayName: first(entry.displayName) || first(entry.cn),
email: first(entry.mail),
}));
// Flag entries already present for this tenant (by ldapDn or username) in
@@ -724,10 +724,10 @@ export class LdapService {
// account is still created and counted, this manual-import path's
// display stays as-is.
let createEmail: string | undefined =
mappedData['email'] || `${username}@ldap.local`;
if (mappedData['email']) {
mappedData.email || `${username}@ldap.local`;
if (mappedData.email) {
const decision = await this.resolveEmailForWrite(
mappedData['email'],
mappedData.email,
null,
);
createEmail = decision.collides ? undefined : decision.email;
@@ -736,7 +736,7 @@ export class LdapService {
await this.userService.create({
username,
...(createEmail && { email: createEmail }),
displayName: mappedData['displayName'],
displayName: mappedData.displayName,
role: 'USER',
tenantId,
ldapDn: dn,
@@ -813,14 +813,14 @@ export class LdapService {
const entry = searchEntries[0];
const record = entry as unknown as Record<string, unknown>;
const guidValue = record['objectGUID'];
const guidValue = record.objectGUID;
if (!Buffer.isBuffer(guidValue)) {
result.errors.push(`${dn}: objectGUID not readable`);
continue;
}
const ldapObjectGuid = guidValue.toString('hex');
const rawName = record['cn'];
const rawName = record.cn;
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
@@ -1436,7 +1436,7 @@ export class LdapService {
string,
unknown
>;
const backfillGuid = backfillRecord['objectGUID'];
const backfillGuid = backfillRecord.objectGUID;
if (!Buffer.isBuffer(backfillGuid)) {
result.errors.push(
`Gruppe ${group.name}: Alt-Bindung ${group.ldapDn} ohne lesbaren objectGUID`,
@@ -1492,7 +1492,7 @@ export class LdapService {
if (hit) {
// 3. Rename/DN reconciliation (SC-3).
const hitRecord = hit as unknown as Record<string, unknown>;
const rawName = hitRecord['cn'];
const rawName = hitRecord.cn;
const name = Array.isArray(rawName)
? String(rawName[0])
: rawName
@@ -76,7 +76,7 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
const scoped = forTenant(fakePrisma, 'tenant-a') as any;
let queryCallCount = 0;
const query = (args: unknown) => {
const query = (_args: unknown) => {
queryCallCount += 1;
return fakeQueryResult;
};
@@ -97,7 +97,7 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
it('setzt den Mandantenkontext ueber ein getaggtes $executeRaw-Template, nicht ueber zusammengebauten Text', async () => {
const fakePrisma: any = {
$transaction: vi.fn((arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])),
$transaction: vi.fn((_arg: unknown) => Promise.resolve(['set-config-result', 'query-result'])),
$extends: (config: any) => ({
async __invoke(args: unknown, query: (args: unknown) => unknown) {
return config.query.$allOperations({ args, query });
@@ -286,7 +286,7 @@ describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebund
it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => {
const setConfigCalls: unknown[] = [];
const fakeTx: any = {
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
$executeRaw: vi.fn((_strings: TemplateStringsArray, ...values: unknown[]) => {
setConfigCalls.push(values);
return Promise.resolve(1);
}),
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CosinexAdapter } from './cosinex.adapter';
@@ -1,6 +1,6 @@
import AdmZip from 'adm-zip';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildDoeNoticeUrl, DoeOpenDataAdapter } from './doe-opendata.adapter';
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Logger } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import {
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { CryptoService } from '../../crypto/crypto.service';
import * as cheerio from 'cheerio';
import { createHash } from 'crypto';
import { createHash } from 'node:crypto';
import { ExchangeInboxProvider } from '../../inbox/exchange-inbox.provider';
import { ImapProvider } from '../../inbox/imap.provider';
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { NetServerAdapter } from './netserver.adapter';
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RssAdapter } from './rss.adapter';
+1 -1
View File
@@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { createHash } from 'crypto';
import { createHash } from 'node:crypto';
import { XMLParser } from 'fast-xml-parser';
import { PrismaService } from '../../prisma/prisma.service';
import type { RawTenderRecord, SourceType } from '../tender.types';
@@ -235,8 +235,8 @@ describe('TenderDigestScheduler — Multi-Tenant via findMany (Pitfall 1)', () =
});
it('never uses findFirst anywhere in the module source — findMany over all due users is mandatory (Pitfall 1)', async () => {
const { readFileSync } = await import('fs');
const { join } = await import('path');
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const source = readFileSync(join(__dirname, 'tender-digest.scheduler.ts'), 'utf8');
expect(source).not.toMatch(/findFirst/);
});
@@ -168,7 +168,7 @@ export class TenderDigestScheduler implements OnModuleInit {
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
// Mailversand wird uebersprungen (zugesagtes Verhalten).
if (!user || !user.email) continue;
if (!user?.email) continue;
const sections = groupMatchesByProfile(matches);
const sent = await this.mail.sendDigest({ email: user.email }, user.tenantId, sections);
+1 -1
View File
@@ -1,4 +1,4 @@
import { createHash } from 'crypto';
import { createHash } from 'node:crypto';
/**
* tenderFingerprint — pure, deterministic cross-source dedup key (SCHEMA-03,
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { TenderIngestionService } from './tender-ingestion.service';
@@ -150,7 +150,7 @@ export class TenderMatchingService {
// Kein Konto, oder ein Konto ohne Adresse (WINDOWS #15, kollidierte
// AD-Adresse) -- die Zugehoerigkeit funktioniert, nur der
// Mailversand wird uebersprungen (zugesagtes Verhalten).
if (!user || !user.email) continue;
if (!user?.email) continue;
const sent = await this.mail.sendInstant(
{ email: user.email },
@@ -1,7 +1,7 @@
import AdmZip from 'adm-zip';
import { XMLParser } from 'fast-xml-parser';
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { RawTenderRecord, SourceType } from './tender.types';
import { TenderNormalizerService } from './tender-normalizer.service';
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'crypto';
import { createHash } from 'node:crypto';
import { divisionOf } from './cpv/cpv-catalog';
import { bundeslandFromRegion } from './geo/nuts-bundesland';
import type { NormalizedTenderFields, RawTenderRecord } from './tender.types';
@@ -64,7 +64,7 @@ function makeFakePrisma() {
const tenderRssFeedSource = {
findMany: async ({ where, orderBy }: any = {}) => {
let all = [...rows.values()].filter((row) => matchesWhere(row, where));
const all = [...rows.values()].filter((row) => matchesWhere(row, where));
if (orderBy?.createdAt === 'asc') {
all.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
}
@@ -1,5 +1,5 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { TenderSchedulerService } from './tender-scheduler.service';
+3 -3
View File
@@ -16,8 +16,8 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
@@ -293,7 +293,7 @@ export class UserController {
@UploadedFile() file: any,
@CurrentUser() currentUser: any,
) {
if (!file || !file.buffer) {
if (!file?.buffer) {
throw new BadRequestException('No file provided');
}
+4 -4
View File
@@ -170,7 +170,7 @@
</head>
<body>
<div class="setup-card">
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="56" height="56">
<svg class="brand-mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="56" height="56">
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect>
<g>
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
@@ -312,12 +312,12 @@
return;
}
showInfo('Tessera ' + version + ' gefunden – Verbindung wird hergestellt …');
showInfo(`Tessera ${version} gefunden – Verbindung wird hergestellt …`);
try {
await invoke('save_server_url', { url: normalizedUrl });
} catch (err) {
showError('Die Adresse konnte nicht gespeichert werden: ' + String(err));
showError(`Die Adresse konnte nicht gespeichert werden: ${String(err)}`);
connectBtn.disabled = false;
connectBtn.textContent = 'Verbinden';
}
@@ -344,7 +344,7 @@
*/
function enterChangeMode(current) {
urlInput.value = current;
currentServer.textContent = 'Aktuell verbunden mit: ' + current;
currentServer.textContent = `Aktuell verbunden mit: ${current}`;
currentServer.style.display = 'block';
subtitle.textContent = 'Server-Adresse ändern';
cancelBtn.hidden = false;
+1 -2
View File
@@ -2,7 +2,6 @@
import { useState, useTransition } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { login } from '@/lib/auth-actions';
import { sanitizeNextPath } from '@/lib/safe-next';
@@ -18,7 +17,6 @@ import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-link
*/
export default function LoginPage() {
const t = useTranslations('auth');
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
@@ -165,6 +163,7 @@ export default function LoginPage() {
{isPending ? (
<svg
className="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -170,6 +170,7 @@ export default function ResetPasswordTokenPage() {
{isPending ? (
<svg
className="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -110,6 +110,7 @@ export default function ResetPasswordPage() {
{isPending ? (
<svg
className="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -114,12 +114,14 @@ export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDial
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting || loadingImpact || impactError}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
@@ -83,8 +83,9 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t('name')}</label>
<label htmlFor="group-name" className="text-sm font-medium text-foreground">{t('name')}</label>
<input
id="group-name"
type="text"
required
disabled={isImported}
@@ -113,10 +114,11 @@ export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps)
</p>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="group-internal-name" className="text-sm font-medium text-foreground">
{t('internalName')}
</label>
<input
id="group-internal-name"
type="text"
value={internalName}
onChange={(e) => setInternalName(e.target.value)}
@@ -126,6 +126,7 @@ export default function AdminGroupsPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
@@ -146,6 +147,7 @@ export default function AdminGroupsPage() {
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
@@ -205,6 +207,7 @@ export default function AdminGroupsPage() {
}`}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -223,18 +226,21 @@ export default function AdminGroupsPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setMembersGroup(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{t('membersButton')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(group)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -20,6 +20,7 @@ export default function AdminLayout({
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
+25 -10
View File
@@ -557,10 +557,11 @@ export default function AdminLdapPage() {
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-server-url" className="text-sm font-medium text-foreground">
{t('serverUrl')}
</label>
<input
id="ldap-server-url"
type="text"
value={formData.serverUrl}
onChange={(e) => setFormData({ ...formData, serverUrl: e.target.value })}
@@ -570,10 +571,11 @@ export default function AdminLdapPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-base-dn" className="text-sm font-medium text-foreground">
{t('baseDn')}
</label>
<textarea
id="ldap-base-dn"
value={formData.baseDn}
onChange={(e) => setFormData({ ...formData, baseDn: e.target.value })}
placeholder={'dc=example,dc=com\nou=extern,dc=example,dc=com'}
@@ -584,10 +586,11 @@ export default function AdminLdapPage() {
<p className="text-xs text-muted-foreground">{t('baseDnHint')}</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-bind-dn" className="text-sm font-medium text-foreground">
{t('bindDn')}
</label>
<input
id="ldap-bind-dn"
type="text"
value={formData.bindDn}
onChange={(e) => setFormData({ ...formData, bindDn: e.target.value })}
@@ -596,10 +599,11 @@ export default function AdminLdapPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-bind-password" className="text-sm font-medium text-foreground">
{t('bindPassword')}
</label>
<input
id="ldap-bind-password"
type="password"
value={formData.bindPassword}
onChange={(e) => setFormData({ ...formData, bindPassword: e.target.value })}
@@ -609,10 +613,11 @@ export default function AdminLdapPage() {
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-search-filter" className="text-sm font-medium text-foreground">
{t('searchFilter')}
</label>
<input
id="ldap-search-filter"
type="text"
value={formData.searchFilter}
onChange={(e) => setFormData({ ...formData, searchFilter: e.target.value })}
@@ -677,6 +682,7 @@ export default function AdminLdapPage() {
{t('fieldMapping.title')}
</h2>
<button
type="button"
onClick={() => setShowMappingForm(true)}
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
@@ -725,6 +731,7 @@ export default function AdminLdapPage() {
strokeLinejoin="round"
className="text-muted-foreground"
>
<title>{t('fieldMapping.defaultIcon')}</title>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
@@ -733,6 +740,7 @@ export default function AdminLdapPage() {
<td className="px-4 py-3 text-right">
{!mapping.isDefault && (
<button
type="button"
onClick={() => handleRemoveMapping(mapping.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -750,10 +758,11 @@ export default function AdminLdapPage() {
{showMappingForm && (
<form onSubmit={handleAddMapping} className="mt-4 flex items-end gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ldap-mapping-ldap-field" className="text-xs font-medium text-muted-foreground">
{t('fieldMapping.ldapField')}
</label>
<input
id="ldap-mapping-ldap-field"
type="text"
value={newMapping.ldapField}
onChange={(e) => setNewMapping({ ...newMapping, ldapField: e.target.value })}
@@ -762,10 +771,11 @@ export default function AdminLdapPage() {
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ldap-mapping-tessera-field" className="text-xs font-medium text-muted-foreground">
{t('fieldMapping.tesseraField')}
</label>
<input
id="ldap-mapping-tessera-field"
type="text"
value={newMapping.tesseraField}
onChange={(e) => setNewMapping({ ...newMapping, tesseraField: e.target.value })}
@@ -857,10 +867,11 @@ export default function AdminLdapPage() {
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ldap-group-manual-dn" className="text-xs font-medium text-muted-foreground">
{t('groupFilter.manualDn')}
</label>
<input
id="ldap-group-manual-dn"
type="text"
value={manualDn}
onChange={(e) => setManualDn(e.target.value)}
@@ -1165,10 +1176,11 @@ export default function AdminLdapPage() {
<div className="flex items-end gap-3 mb-4">
<div className="flex-1 space-y-1">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor="ldap-exclude-username" className="text-xs font-medium text-muted-foreground">
{t('userExclude.username')}
</label>
<input
id="ldap-exclude-username"
type="text"
value={newExcludeUser}
onChange={(e) => setNewExcludeUser(e.target.value)}
@@ -1240,11 +1252,12 @@ export default function AdminLdapPage() {
{/* Sync interval */}
<div className="flex items-center gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-foreground">
<label htmlFor="ldap-sync-interval" className="text-sm font-medium text-foreground">
{t('sync.interval')}
</label>
<div className="flex items-center gap-2">
<input
id="ldap-sync-interval"
type="number"
min={0}
value={formData.syncIntervalMin}
@@ -1281,6 +1294,7 @@ export default function AdminLdapPage() {
{/* Save interval changes */}
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
@@ -1302,6 +1316,7 @@ export default function AdminLdapPage() {
{/* Manual sync button (D-14) */}
<button
type="button"
onClick={handleSync}
disabled={syncing}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
@@ -197,6 +197,7 @@ export default function AdminModulesPage() {
{isActive ? t('activated') : t('deactivated')}
</span>
<button
type="button"
onClick={() => {
if (isActive) {
// Deactivation is unchanged -- direct toggle, no dialog.
@@ -148,6 +148,7 @@ export default function AdminTenantsPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
@@ -211,18 +212,21 @@ export default function AdminTenantsPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => handleToggleActive(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tenant.isActive ? tCommon('inactive') : tCommon('active')}
</button>
<button
type="button"
onClick={() => setDeleteConfirm(tenant.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
@@ -246,10 +250,11 @@ export default function AdminTenantsPage() {
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="tenant-name" className="text-sm font-medium text-foreground">
{t('name')}
</label>
<input
id="tenant-name"
type="text"
required
value={formData.name}
@@ -261,10 +266,11 @@ export default function AdminTenantsPage() {
</div>
{!editingTenant && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="tenant-slug" className="text-sm font-medium text-foreground">
{t('slug')}
</label>
<input
id="tenant-slug"
type="text"
required
pattern="^[a-z0-9-]+$"
@@ -306,12 +312,14 @@ export default function AdminTenantsPage() {
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={() => handleDelete(deleteConfirm)}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
>
+16 -5
View File
@@ -229,6 +229,7 @@ export default function AdminUsersPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
@@ -308,6 +309,7 @@ export default function AdminUsersPage() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setDetailsUser(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
@@ -315,6 +317,7 @@ export default function AdminUsersPage() {
</button>
{canManageRow(user) && (
<button
type="button"
onClick={() => openEdit(user)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
@@ -323,6 +326,7 @@ export default function AdminUsersPage() {
)}
{canManageRow(user) && (
<button
type="button"
onClick={() => {
setDeleteConfirm(user.id);
setDeleteError(null);
@@ -351,10 +355,11 @@ export default function AdminUsersPage() {
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="user-username" className="text-sm font-medium text-foreground">
{t('username')}
</label>
<input
id="user-username"
type="text"
required
value={formData.username}
@@ -365,10 +370,11 @@ export default function AdminUsersPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="user-email" className="text-sm font-medium text-foreground">
{t('email')}
</label>
<input
id="user-email"
type="email"
required
value={formData.email}
@@ -379,7 +385,7 @@ export default function AdminUsersPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="user-password" className="text-sm font-medium text-foreground">
{t('password')}
{editingUser && (
<span className="ml-1 text-xs text-muted-foreground">
@@ -388,6 +394,7 @@ export default function AdminUsersPage() {
)}
</label>
<input
id="user-password"
type="password"
required={!editingUser}
minLength={8}
@@ -399,10 +406,11 @@ export default function AdminUsersPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="user-display-name" className="text-sm font-medium text-foreground">
{t('displayName')}
</label>
<input
id="user-display-name"
type="text"
value={formData.displayName}
onChange={(e) =>
@@ -412,10 +420,11 @@ export default function AdminUsersPage() {
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
<label htmlFor="user-role" className="text-sm font-medium text-foreground">
{t('role')}
</label>
<select
id="user-role"
value={formData.role}
onChange={(e) =>
setFormData({
@@ -479,12 +488,14 @@ export default function AdminUsersPage() {
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
{tCommon('cancel')}
</button>
<button
type="button"
onClick={() => handleDelete(deleteConfirm)}
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity"
>
@@ -2,14 +2,10 @@
import { useState, useTransition, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { fetchCurrentUser, changePasswordAction } from '@/lib/auth-actions';
import { useAuthStore } from '@/lib/stores/auth-store';
export default function ChangePasswordPage() {
const t = useTranslations('auth');
const router = useRouter();
const { user, setUser } = useAuthStore();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [passwordMismatch, setPasswordMismatch] = useState(false);
@@ -139,6 +135,7 @@ export default function ChangePasswordPage() {
{isPending ? (
<svg
className="animate-spin h-4 w-4"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -134,7 +134,7 @@ export default function ModuleDetailPage() {
return (
<div className="max-w-2xl mx-auto space-y-4">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
{t('backToMarketplace')}
@@ -150,7 +150,7 @@ export default function ModuleDetailPage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
{t('backToMarketplace')}
@@ -159,13 +159,13 @@ export default function ModuleDetailPage() {
<div className="flex items-start gap-4">
<div className="rounded-lg bg-muted p-4 shrink-0">
{module.icon === 'Globe' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
</svg>
)}
@@ -187,6 +187,7 @@ export default function ModuleDetailPage() {
</span>
{isAdmin && (
<button
type="button"
onClick={handleToggle}
disabled={toggling}
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
@@ -52,6 +52,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
</p>
<div className="flex justify-end gap-3">
<button
type="button"
ref={cancelRef}
onClick={onCancel}
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
@@ -59,6 +60,7 @@ export function ActivationDialog({ open, moduleName, onConfirm, onCancel }: Acti
{t('deactivateDialogCancel')}
</button>
<button
type="button"
ref={confirmRef}
onClick={onConfirm}
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
@@ -14,7 +14,7 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
return (
<div className="flex gap-2 overflow-x-auto pb-1">
<button
role="button"
type="button"
onClick={() => onChange('all')}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === 'all'
@@ -27,7 +27,7 @@ export function CategoryFilter({ categories, value, onChange }: CategoryFilterPr
{categories.map((cat) => (
<button
key={cat}
role="button"
type="button"
onClick={() => onChange(cat)}
className={`rounded-full px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
value === cat
@@ -33,6 +33,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -54,6 +55,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -167,6 +169,7 @@ export function MarketplaceCard({
{canManage && (
<div className="flex items-center justify-end">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle(id, isActive);
@@ -33,11 +33,12 @@ export function MarketplaceSearch({ onChange }: MarketplaceSearchProps) {
/>
{value && (
<button
type="button"
onClick={() => setValue('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
@@ -24,6 +24,7 @@ export function StatusFilter({ value, counts, onChange }: StatusFilterProps) {
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={value === tab.key}
onClick={() => onChange(tab.key)}
@@ -38,13 +38,14 @@ export function TenantContextSelector() {
return (
<div className="bg-muted/50 rounded-lg p-3 mb-4 border border-border">
<label className="block text-xs font-semibold text-muted-foreground mb-1.5">
<label htmlFor="tenant-context-select" className="block text-xs font-semibold text-muted-foreground mb-1.5">
{t('tenantContextLabel')}
</label>
{loading ? (
<span className="text-sm text-muted-foreground">Laden...</span>
) : (
<select
id="tenant-context-select"
onChange={(e) => setSelectedTenantId(e.target.value || null)}
defaultValue=""
className="bg-card border border-border rounded-md px-3 py-1.5 text-sm min-w-[200px]"
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { create } from 'zustand';
interface Toast {
@@ -27,6 +28,7 @@ export const useToastStore = create<ToastState>()((set) => ({
}));
export function ToastContainer() {
const t = useTranslations('common');
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
@@ -40,21 +42,23 @@ export function ToastContainer() {
className="flex items-center gap-3 rounded-lg border border-border bg-card p-4 shadow-lg motion-safe:animate-[slideIn_200ms_ease-out]"
>
{toast.type === 'success' ? (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600 dark:text-green-400 shrink-0">
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-destructive shrink-0">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-destructive shrink-0">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
)}
<span className="text-sm text-foreground">{toast.message}</span>
<button
type="button"
onClick={() => removeToast(toast.id)}
aria-label={t('close')}
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
@@ -200,6 +200,7 @@ export default function MarketplacePage() {
) : modules.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[40vh] text-center">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
@@ -40,6 +40,7 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
@@ -81,6 +82,7 @@ export function ModuleShell({ category, moduleSlug }: ModuleShellProps) {
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -23,6 +23,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
if (icon === 'Globe') {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -44,6 +45,7 @@ function ModuleIcon({ icon }: { icon?: string }) {
// Default puzzle-piece icon for generic modules
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -121,6 +121,7 @@ export default function CategoryPage() {
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-lg bg-muted p-4 mb-4">
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
@@ -1,4 +1,4 @@
import { cleanup, render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl — provide certManager namespace keys
@@ -73,6 +73,7 @@ export function ConvertTab({ file, pemText, password, onTargetFormatChange }: Co
{/* Primary action button */}
<button
type="button"
onClick={handleConvert}
disabled={loading || !canConvert}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
@@ -28,7 +28,7 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(true);
};
@@ -37,7 +37,7 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
@@ -55,36 +55,35 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
className="hidden"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
{/* a11y/useSemanticElements: kein role="button"-div mehr -- die
eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
Ereignis-Handlern waere ein statisches, nicht interaktives Element
und faellt unter die zurueckgestellten Regeln noStaticElement-
Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
<div className="relative">
<button
type="button"
onClick={handleClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`rounded-lg border-2 border-dashed p-8 text-center cursor-pointer transition-colors ${
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
{currentFile ? (
<div className="flex items-center justify-center gap-3">
<span className="text-sm text-foreground">
{currentFile.name}{' '}
<span className="text-muted-foreground">
({(currentFile.size / 1024).toFixed(1)} KB)
</span>
</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="text-muted-foreground hover:text-foreground text-xs border border-border rounded px-2 py-0.5"
>
&#x2715;
</button>
</div>
) : (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
@@ -93,6 +92,16 @@ export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
</div>
)}
</button>
{currentFile && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
)}
</div>
</div>
);
@@ -43,6 +43,7 @@ export function InspectTab({ file, pemText, password }: InspectTabProps) {
<div className="space-y-4">
{/* Primary action button */}
<button
type="button"
onClick={handleInspect}
disabled={loading || !canInspect}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
@@ -18,11 +18,12 @@ export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
return (
<div className="space-y-1">
<label className="block text-sm text-foreground">
<label htmlFor="cert-manager-password" className="block text-sm text-foreground">
{t('password.label')}
</label>
<div className="relative flex items-center">
<input
id="cert-manager-password"
type={revealed ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
@@ -38,6 +39,7 @@ export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
{revealed ? (
/* Eye-off inline SVG */
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -55,6 +57,7 @@ export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
) : (
/* Eye inline SVG */
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -17,7 +17,7 @@ const ROLE_STYLES: Record<CertRole, string> = {
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
};
function downloadAllAsZip(certs: SplitResponse['certs'], t: (k: string) => string) {
function downloadAllAsZip(certs: SplitResponse['certs']) {
const files: Record<string, Uint8Array> = {};
for (const cert of certs) {
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
@@ -70,6 +70,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSplit}
disabled={loading || !file}
className="bg-primary text-primary-foreground px-4 py-2 rounded text-sm font-medium disabled:opacity-50 hover:opacity-90 transition-opacity"
@@ -79,7 +80,8 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
{result && result.certs.length > 1 && (
<button
onClick={() => downloadAllAsZip(result.certs, t)}
type="button"
onClick={() => downloadAllAsZip(result.certs)}
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
>
{t('actions.downloadZip')}
@@ -117,6 +119,7 @@ export function SplitTab({ file, pemText: _pemText, password: _password }: Split
</span>
</div>
<button
type="button"
onClick={() =>
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
}
@@ -161,7 +161,7 @@ export function VehicleTable() {
// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<DkvVehicle | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [, setIsDeleting] = useState(false);
// Server error below table
const [tableError, setTableError] = useState<string | null>(null);
@@ -260,9 +260,9 @@ export function EmailAlertConfigForm() {
{/* Host / EWS-URL */}
<div>
<label htmlFor="email-alert-host" className={labelCls}>
{(form.protocol === 'exchange'
{`${form.protocol === 'exchange'
? t('emailAlerts.hostLabelExchange')
: t('emailAlerts.hostLabelImap')) + ' *'}
: t('emailAlerts.hostLabelImap')} *`}
</label>
<input
id="email-alert-host"
+2
View File
@@ -81,10 +81,12 @@ export default function DashboardPage() {
<div className="fixed bottom-6 right-6 z-20 flex items-center gap-2">
{isEditMode && (
<button
type="button"
onClick={() => setCatalogOpen(true)}
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -26,6 +26,7 @@ export default function SettingsLayout({
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect><g><rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"></rect><rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"></rect></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72"><title>Tessera</title><rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect><g><rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"></rect><rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"></rect><rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"></rect></g></svg>

Before

Width:  |  Height:  |  Size: 543 B

After

Width:  |  Height:  |  Size: 565 B

+12 -12
View File
@@ -17,7 +17,7 @@ export function AdminSidebar() {
href: '/admin/users',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
@@ -30,7 +30,7 @@ export function AdminSidebar() {
href: '/admin/tenants',
show: isSuperAdmin,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
@@ -41,7 +41,7 @@ export function AdminSidebar() {
href: '/admin/modules',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
@@ -53,7 +53,7 @@ export function AdminSidebar() {
href: '/admin/ldap',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
@@ -65,7 +65,7 @@ export function AdminSidebar() {
href: '/admin/smtp',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
@@ -76,7 +76,7 @@ export function AdminSidebar() {
href: '/admin/groups',
show: true,
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" />
<line x1="8" x2="21" y1="12" y2="12" />
<line x1="8" x2="21" y1="18" y2="18" />
@@ -89,17 +89,17 @@ export function AdminSidebar() {
];
return (
<aside
className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar"
role="navigation"
aria-label={t('admin.label')}
>
// a11y/useSemanticElements: role="navigation" nicht auf das <aside> gehoben --
// das <nav> darunter trAegt die Navigationssemantik schon; ein zweites
// Navigations-Landmark auf dem Rahmen waere ein doppeltes Landmark fuer
// dieselbe Sache. Der aria-label sitzt darum am <nav>, nicht am <aside>.
<aside className="flex w-[220px] shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
<div className="p-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('admin.label')}
</h2>
</div>
<nav className="flex flex-col gap-1 px-3">
<nav className="flex flex-col gap-1 px-3" aria-label={t('admin.label')}>
{items
.filter((item) => item.show)
.map((item) => (
@@ -19,6 +19,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
return (
<button
type="button"
onClick={onToggle}
className={`inline-flex items-center justify-center rounded-md p-2 shadow-lg transition-colors ${
isEditMode
@@ -32,6 +33,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
{isEditMode ? (
// Checkmark icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
@@ -47,6 +49,7 @@ export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
) : (
// Pencil icon
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
@@ -76,11 +76,13 @@ export function WidgetCatalogModal({
{t('catalogTitle')}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Close"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
@@ -105,6 +107,7 @@ export function WidgetCatalogModal({
return (
<button
key={type}
type="button"
onClick={() => {
onAddWidget(type);
onClose();
@@ -90,6 +90,7 @@ function PlaceholderWidget({ config }: WidgetProps) {
function ClockIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -110,6 +111,7 @@ function ClockIcon({ className }: { className?: string }) {
function SearchIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -130,6 +132,7 @@ function SearchIcon({ className }: { className?: string }) {
function CalendarIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -152,6 +155,7 @@ function CalendarIcon({ className }: { className?: string }) {
function NoteIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -175,6 +179,7 @@ function NoteIcon({ className }: { className?: string }) {
function CalculatorIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -203,6 +208,7 @@ function CalculatorIcon({ className }: { className?: string }) {
function FavoritesIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -222,6 +228,7 @@ function FavoritesIcon({ className }: { className?: string }) {
function StopwatchIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -322,7 +322,13 @@ export function CalculatorWidget({ isEditMode }: WidgetProps) {
return (
<div
className="flex h-full flex-col gap-1 p-1 select-none"
tabIndex={0}
// a11y/noNoninteractiveTabindex: -1 statt 0 -- die Zahlentasten sind
// native <button>-Elemente und damit selbst Teil der Tab-Reihenfolge;
// Tastendruecke erreichen handleKeyboard weiterhin per Bubbling, sobald
// eine dieser Tasten fokussiert ist. -1 haelt den Container weiterhin
// programmatisch fokussierbar, entfernt aber den zusaetzlichen,
// wirkungslosen Tab-Stopp auf dem Container selbst.
tabIndex={-1}
role="application"
aria-label={t('calculator.name')}
onKeyDown={handleKeyboard}
@@ -50,7 +50,6 @@ export function ClockWidget({ config }: WidgetProps) {
verwirft clamp() im Inline-Style, der Browser bekommt die Klasse
ohnehin — und "auto = Klasse, fest = Zahl" ist die einfachere Form. */}
<time
role="time"
data-font-mode={fixedPt === null ? 'auto' : 'fixed'}
className="font-semibold tabular-nums leading-none text-foreground text-[clamp(12px,min(20cqw,50cqh),400px)]"
style={fixedPt === null ? undefined : { fontSize: `${fixedPt}pt` }}
@@ -1,6 +1,6 @@
'use client';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { updateWidgetConfig } from '@/lib/dashboard-api';
import {
@@ -157,11 +157,13 @@ export function NoteWidget({ instanceId, config }: WidgetProps) {
/>
)}
<button
type="button"
onClick={() => setIsEditing((v) => !v)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors hover:bg-muted ${isEditing ? 'text-destructive' : 'text-muted-foreground'}`}
title={isEditing ? t('note.viewMode') : t('note.editMode')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
@@ -89,7 +89,6 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
<div className="flex h-full items-center gap-2 px-1.5">
{/* Provider dropdown (D-14: ~120px left) */}
<select
role="combobox"
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
value={selectedProviderId}
onChange={(e) => handleProviderChange(e.target.value)}
@@ -116,8 +115,10 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
type="button"
className="flex h-8 shrink-0 items-center justify-center rounded bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
onClick={handleSearch}
aria-label={t('search.searchButton')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -35,10 +35,9 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
: 'relative h-full w-full overflow-hidden rounded-lg border border-primary/20 bg-card shadow-sm';
return (
<div
<article
className={cardClassName}
data-widget-id={widget.id}
role="article"
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
>
{/* Edit mode: Overlay-Kopfleiste (Griff-Hinweis) mit Loesch-Knopf rechts.
@@ -78,6 +77,7 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
data-no-drag=""
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
@@ -116,6 +116,6 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
</div>
)}
</div>
</div>
</article>
);
}
+7 -1
View File
@@ -99,11 +99,13 @@ export function Header() {
{/* Left: Logo + Mobile hamburger */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
aria-label={t('common.menu')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
@@ -145,6 +147,7 @@ export function Header() {
{/* User avatar dropdown */}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
aria-label={tHeader('userMenu')}
@@ -192,6 +195,7 @@ export function Header() {
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -221,7 +225,7 @@ export function Header() {
: 'text-foreground hover:bg-muted'
}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
{tHeader('admin.label')}
@@ -233,6 +237,7 @@ export function Header() {
<div className="border-t border-border my-1" />
<div className="px-2 py-1">
<button
type="button"
onClick={() => {
setDropdownOpen(false);
logout();
@@ -240,6 +245,7 @@ export function Header() {
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
@@ -28,8 +28,13 @@ export function SidebarFooter() {
)}
{/* Settings */}
<button className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors">
<button
type="button"
aria-label={t('settings')}
className="flex items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
+13 -8
View File
@@ -21,6 +21,7 @@ interface SidebarModule {
export function Sidebar() {
const t = useTranslations('sidebar');
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -94,8 +95,8 @@ export function Sidebar() {
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
<li>
<Link href="/" className={linkClass('/')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
@@ -105,8 +106,8 @@ export function Sidebar() {
</Link>
</li>
<li>
<Link href="/marketplace" className={linkClass('/marketplace')}>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
@@ -140,6 +141,7 @@ export function Sidebar() {
{filteredCategories.map(([category, mods]) => (
<li key={category}>
<button
type="button"
onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
isActive(`/modules/${category}`)
@@ -147,13 +149,13 @@ export function Sidebar() {
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">{mods.length}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
@@ -188,11 +190,12 @@ export function Sidebar() {
<div className="hidden md:block border-t border-sidebar-border p-2">
<button
type="button"
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
aria-label={isCollapsed ? t('expand') : t('collapse')}
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" />
</svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
@@ -241,10 +244,12 @@ export function Sidebar() {
wordmarkClassName="dark:text-primary"
/>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
@@ -22,6 +22,7 @@ export function LocaleSwitcher() {
return (
<button
type="button"
onClick={switchLocale}
disabled={isPending}
className="inline-flex items-center justify-center rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"

Some files were not shown because too many files have changed in this diff Show More