Compare commits
15 Commits
6de5eb4f07
...
c001a081c2
| Author | SHA1 | Date | |
|---|---|---|---|
| c001a081c2 | |||
| 97a6836444 | |||
| c79bafa179 | |||
| 21c85a8b88 | |||
| 73ac08af17 | |||
| 27a6e2952c | |||
| 969fd01f5c | |||
| 278aedb201 | |||
| ae821254ef | |||
| 4cff3167ed | |||
| e76f3b8d84 | |||
| 38115783b4 | |||
| 636fe0df8f | |||
| 8d1c8f320b | |||
| e7c2c4c6a9 |
+6
-5
@@ -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
|
||||
|
||||
+680
@@ -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>
|
||||
+439
@@ -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*
|
||||
+111
File diff suppressed because one or more lines are too long
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
✕
|
||||
</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"
|
||||
>
|
||||
✕
|
||||
</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);
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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 @@
|
||||
<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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user