feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens
Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus Laufzettel übernommen, weil dort derselbe Stack erprobt ist. Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das helle Theme setzt dieselben Namen neu. Amber und Rot sind dort abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide Themes gegen 4,5:1. Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank, Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf 1440 px und 390 px. Closes #001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,41 @@
|
||||
# ── Datenbank ────────────────────────────────────────────────────────────────
|
||||
DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy
|
||||
# Nur für Tests; wird vor jedem Test geleert.
|
||||
TEST_DATABASE_URL=postgresql://proxdeploy:proxdeploy@localhost:5437/proxdeploy_test
|
||||
|
||||
# ── Geheimnisse ──────────────────────────────────────────────────────────────
|
||||
# Schlüssel für AES-256-GCM (Proxmox-Token, SSH-Schlüssel, OIDC-Secret): genau 32 Byte, base64.
|
||||
# Erzeugen: openssl rand -base64 32 – NIE ändern, sonst sind gespeicherte Geheimnisse unlesbar.
|
||||
PROXDEPLOY_SCHLUESSEL=
|
||||
# Signiert die Sitzungscookies. Erzeugen: openssl rand -base64 48
|
||||
SITZUNG_GEHEIMNIS=
|
||||
|
||||
# ── Adresse und Reverse-Proxy ────────────────────────────────────────────────
|
||||
# Öffentliche Adresse, wie Browser und CLI sie sehen (ohne Schrägstrich am Ende).
|
||||
APP_URL=http://localhost:3000
|
||||
# true hinter einem Reverse-Proxy: X-Forwarded-For/-Proto werden ausgewertet.
|
||||
TRUST_PROXY=false
|
||||
|
||||
# ── Erster Administrator ─────────────────────────────────────────────────────
|
||||
# Wird nur angelegt, solange es noch keinen Benutzer gibt. Danach wirkungslos.
|
||||
ADMIN_BENUTZER=admin
|
||||
ADMIN_PASSWORT=
|
||||
|
||||
# ── OIDC (optional, z. B. authentik) ─────────────────────────────────────────
|
||||
OIDC_AKTIV=false
|
||||
OIDC_ISSUER=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_ANZEIGENAME=SSO
|
||||
# Gruppen-Claim und Gruppennamen für die Rollen.
|
||||
OIDC_GRUPPEN_CLAIM=groups
|
||||
OIDC_GRUPPE_ADMIN=proxdeploy-admins
|
||||
OIDC_GRUPPE_OPERATOR=proxdeploy-operators
|
||||
|
||||
# ── Worker ───────────────────────────────────────────────────────────────────
|
||||
# Wie viele Aufträge gleichzeitig laufen dürfen.
|
||||
WORKER_PARALLEL=4
|
||||
|
||||
# ── Logs ─────────────────────────────────────────────────────────────────────
|
||||
# trace | debug | info | warn | error
|
||||
LOG_LEVEL=info
|
||||
@@ -0,0 +1 @@
|
||||
pnpm exec commitlint --edit "$1"
|
||||
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,12 @@
|
||||
.next
|
||||
node_modules
|
||||
coverage
|
||||
docs
|
||||
*.md
|
||||
pnpm-lock.yaml
|
||||
src/generated
|
||||
prisma/migrations
|
||||
cli/
|
||||
catalog/
|
||||
test-results
|
||||
public/brand
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 110
|
||||
}
|
||||
@@ -10,6 +10,10 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
### Hinzugefügt
|
||||
- Architektur- und Umsetzungsplan (`docs/architektur.md`) mit Prüfung der Proxmox-API 8.x/9.x und der Image-Quellen (#001)
|
||||
- Issues #001–#020 für die sechs Etappen und den Feldtest (#001)
|
||||
- Grundgerüst: Next.js 16, Prisma 7, Tailwind 4 mit den ProxDeploy-Farben als Tokens, dunkles und helles Theme (#001)
|
||||
- Anmeldung mit lokalem Benutzer; der erste Administrator entsteht aus `ADMIN_BENUTZER`/`ADMIN_PASSWORT` (#001)
|
||||
- Seitenleiste mit Wortmarke, auf dem Handy als Schublade; Übersichtsseite (#001)
|
||||
- `/api/v1/gesundheit` und `/api/v1/ich` (#001)
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -18,3 +22,6 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
### Entfernt
|
||||
|
||||
### Sicherheit
|
||||
- Geheimnisse werden mit AES-256-GCM verschlüsselt, Schlüssel aus `PROXDEPLOY_SCHLUESSEL` (#001)
|
||||
- Passwörter mit argon2id; Sperre nach zehn Fehlversuchen in 15 Minuten; Content-Security-Policy mit Nonce (#001)
|
||||
- Das Audit-Log ist per Datenbank-Trigger nur anhängbar (#001)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Konventionen für ProxDeploy
|
||||
|
||||
Diese Datei ist verbindlich. Wer hier etwas ändert, ändert es auch in `docs/architektur.md`.
|
||||
|
||||
## Sprache
|
||||
|
||||
**Deutsch, überall wo ein Mensch liest oder benennt** – auch Bezeichner: Funktionen, Variablen,
|
||||
Prisma-Modelle und -Felder, Enum-Werte, Fehlerklassen, Dateinamen, Kommentare, Testbeschreibungen,
|
||||
Commit-Betreffs. **Umlaute in Bezeichnern ASCII-gefaltet** (`geaendertAm`, `pruefen`), in Texten für
|
||||
Menschen richtig geschrieben („Verbindung prüfen“).
|
||||
|
||||
Englisch bleiben: vom Framework vorgegebene Namen (`GET`, `route.ts`, `page.tsx`, `proxy.ts`),
|
||||
Fachjargon (`Token`, `OIDC`, `VMID`, `Node`, `Storage`, `Bridge`, `Template`, `cloud-init`) und die
|
||||
**YAML-Schlüssel im Katalog** (`catalog/**`) – die sind ein Austauschformat aus der Vorgabe.
|
||||
|
||||
## Namensabbildung zur Vorgabe
|
||||
|
||||
| Vorgabe | Modell / Begriff | Vorgabe | Modell / Begriff |
|
||||
|---|---|---|---|
|
||||
| User | `Benutzer` | Deployment / Job | `Auftrag` (Art `AUSROLLEN`, `TEMPLATE_BAUEN`, `AUFRAEUMEN`) |
|
||||
| API token (CLI) | `ApiToken` | Phase | `Phase` |
|
||||
| SSH key | `SshSchluessel` | Job log line | `Logeintrag` |
|
||||
| Server | `Server` | VMID lock | `VmidReservierung` |
|
||||
| Node | `knoten` (Feld) | Proxmox template | `Template` |
|
||||
| Distribution | `Distribution` | Preset | `Vorlage` |
|
||||
| Profile | `Profil` | Audit log | `Protokoll` |
|
||||
| Setting | `Einstellung` | Key provider | `SchluesselQuelle` |
|
||||
|
||||
API-Pfade sind deutsch: `/api/v1/server`, `/api/v1/auftraege`, `/api/v1/vorlagen`.
|
||||
|
||||
## Schichtung
|
||||
|
||||
```
|
||||
src/engine/* Deploy-Logik, frameworkfrei: kein Next, kein Prisma, kein React.
|
||||
src/server/services/* Regeln und Schreiboperationen; rufen Engine und Prisma.
|
||||
src/server/auth/* Sitzung, Tokens, Rechte (RECHTE + darf()).
|
||||
src/app/api/v1/* Route Handler: Recht prüfen, zod, Service. Sonst nichts.
|
||||
src/components/* Darstellung, keine Geschäftslogik.
|
||||
src/lib/* Framework-freie Helfer; was Client und Server teilen, liegt hier.
|
||||
src/worker/* Hintergrundprozess (pg-boss).
|
||||
```
|
||||
|
||||
- **Web-GUI und CLI nutzen dieselbe REST-API.** Keine Logik nur für eine Oberfläche.
|
||||
- **Phasentexte kommen aus der Engine.** Web und CLI rendern Name, Status, Detail, Dauer, Ursache,
|
||||
Hinweis – sie erfinden keinen Text.
|
||||
- **Jede Seite und jeder Route Handler prüft sein Recht selbst** (`seiteSchuetzen`,
|
||||
`rechtErforderlich`). `tests/zugang.test.ts` prüft das strukturell; Ausnahmen stehen dort mit
|
||||
Begründung. Rolle und Aktiv-Status werden je Anfrage aus der DB gelesen.
|
||||
- **Client-Komponenten importieren nichts aus `src/server/*`.**
|
||||
- **Fehler an Aufrufer** nur als `AnwendungsFehler` (Status, Code, Nachricht, Hinweis); Format
|
||||
`{ fehler: { code, nachricht, hinweis?, felder? } }`.
|
||||
- **zod nur über `@/lib/validierung`** (deutsche Meldungen; ESLint erzwingt das).
|
||||
- **Datenbankregeln** (Trigger, CHECK) stehen in den Migrationen und namentlich in
|
||||
`tests/datenbankregeln.test.ts`.
|
||||
|
||||
## Werkzeuge und Versionen
|
||||
|
||||
Node 24 über nvm (`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"`), pnpm 12. Exakt gepinnt:
|
||||
`next@16.4.0`, `react@19.3.0`, `prisma`/`@prisma/client`/`@prisma/adapter-pg` **`7.10.0`**
|
||||
(der `latest`-Tag von prisma zeigt auf einen 8.0-RC), `vitest@5.0.1`, `@playwright/test@1.63.0`,
|
||||
`pino@10.4.0`. Obergrenzen: **eslint 9.x** (eslint-plugin-react aus eslint-config-next kann 10 nicht),
|
||||
**typescript ~6.0.3** (typescript-eslint 8 erlaubt < 6.1).
|
||||
|
||||
Next 16: `proxy.ts` statt middleware; `cookies()`, `headers()`, `params`, `searchParams` sind async.
|
||||
Prisma 7: Client aus `@/generated/prisma/client`, Enum-Werte aus `@/generated/prisma/enums`, Zugriff
|
||||
über den Lazy-Proxy `src/lib/db.ts`.
|
||||
|
||||
## Vor jedem Commit
|
||||
|
||||
```bash
|
||||
pnpm db:up
|
||||
pnpm lint && pnpm typecheck && pnpm format:pruefen && pnpm test
|
||||
```
|
||||
|
||||
Ohne laufende Datenbank überspringt `pnpm test` die Datenbanktests und sagt es auf stderr.
|
||||
|
||||
## Oberfläche prüfen – immer im gebauten Stand
|
||||
|
||||
```bash
|
||||
pnpm build && PORT=3310 APP_URL=http://127.0.0.1:3310 pnpm start &
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
`next dev` hydriert in dieser Umgebung nicht verlässlich; ein Screenshot beweist dann nichts. Die
|
||||
Abläufe prüfen Desktop (1440 px) und Handy (**390 px, kein horizontales Scrollen**).
|
||||
|
||||
## Git, Versionen, Zeiterfassung
|
||||
|
||||
- Branch `main`; je Issue/Etappe `phase/NNN-kurzname`, nach Abschluss `--ff-only` nach `main`.
|
||||
- Conventional Commits, deutscher Betreff ≤ 72 Zeichen, Körper erklärt das Warum, Fußzeile
|
||||
`Closes #NNN` bzw. `Refs #NNN`. Identität `Florian Wilde <wilde.florian@gmail.com>`.
|
||||
- SemVer; jede Etappe ein annotierter Tag `vX.Y.Z`. **1.0.0 erst nach dem Feldtest** auf echtem
|
||||
Proxmox 8.x und 9.x (#020).
|
||||
- `CHANGELOG.md` (Keep a Changelog, deutsch) und `DEVLOG.md` (Zeit je Issue/Meilenstein aus den
|
||||
Commit-Zeitstempeln, Sitzungen mit „Erledigt“ und „Entscheidungen“) werden mit jedem Issue gepflegt;
|
||||
`docs/issues/*.md` tragen Status und tatsächliche Zeit.
|
||||
|
||||
## Gestaltung
|
||||
|
||||
Farben nur als Tokens aus `src/app/globals.css` (`bg-panel`, `text-muted`, `border-linie`,
|
||||
`text-akzent`, `text-laeuft`, `text-fehler`); keine rohen Hexwerte in Komponenten. Ausnahmen:
|
||||
`themeColor` in `src/app/layout.tsx` (verträgt keine CSS-Variablen) und die Brand-Dateien unter
|
||||
`public/brand/`. Kein Proxmox-Orange. Status immer Symbol + Farbe + Text (`<Status>`).
|
||||
Karten mit 1-px-Linie, Radius 14 px, keine Schatten, keine Verläufe. Kontrast ≥ 4,5:1 in beiden Themes
|
||||
(`tests/kontrast.test.ts`). Icons: Lucide.
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 0 h 25 min (laufende Sitzung)
|
||||
**Gesamt:** 0 h 50 min (laufende Sitzung)
|
||||
|
||||
Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeldungen ist enthalten.
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
| #001 Projekt-Setup | 0 h 25 min (laufend) |
|
||||
| #001 Projekt-Setup | 0 h 50 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -26,6 +26,8 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
- Proxmox-API 8.x/9.x gegen API-Schema 9.2, Quellen der stable-8-Zweige und Release Notes geprüft (Ergebnis: `docs/architektur.md` Abschnitt 3)
|
||||
- Image-URLs, Prüfsummen-Dateien, LXC-Template-Namen, Docker- und Zabbix-Repos per Abruf geprüft
|
||||
- Git-Repository (`main`), CHANGELOG, DEVLOG, Architekturplan und 20 Issues angelegt
|
||||
- Plan freigegeben: Ubuntu 26.04 dazu, Templates mit UEFI, Template-VMID = nächste freie im Template-Bereich, Namensabbildung bestätigt
|
||||
- #001 abgeschlossen: Grundgerüst mit Anmeldung, Seitenleiste, beiden Themes; 60 Tests, 8 Playwright-Abläufe (Desktop + 390 px)
|
||||
|
||||
### Entscheidungen
|
||||
- **VM-Provisionierung immer über den Guest-Agent:** funktioniert ohne SSH und liefert Live-Fortschritt. Snippets braucht es nur beim Template-Bau.
|
||||
@@ -34,4 +36,7 @@ Zeiten stammen aus den Git-Commit-Zeitstempeln je Issue; Wartezeit auf Rückmeld
|
||||
- **Template-Bau per API erst ab PVE 8.4** (`download-url` mit `content=import`); davor über SSH.
|
||||
- **Planer und Ausführung getrennt:** der Dry-Run zeigt exakt den Plan, den der worker danach abarbeitet.
|
||||
- **CLI in Go** mit aus OpenAPI generiertem Client.
|
||||
- **Konfiguration aus Laufzettel übernommen** (gleicher Stack, Versionen dort bereits erprobt); Next auf 16.4.0 wie ProxLedger (Sicherheitsstand).
|
||||
- **Sitzungsgeheimnis und Datenschlüssel getrennt** (`SITZUNG_GEHEIMNIS`, `PROXDEPLOY_SCHLUESSEL`): ein Tausch des Sitzungsgeheimnisses meldet nur alle ab, der Datenschlüssel darf sich nie ändern.
|
||||
- **Amber und Rot im hellen Theme abgedunkelt** (`#8A5A00`, `#B91C1C`): die Originaltöne erreichen auf `#F4F6F8` keine 4,5:1.
|
||||
- **Deutsche Bezeichner** wie in ProxLedger und Laufzettel; YAML-Schlüssel im Katalog bleiben englisch (Vorgabe).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Conventional Commits. Betreff auf Deutsch, hoechstens 72 Zeichen (siehe CLAUDE.md).
|
||||
const konfiguration = {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"header-max-length": [2, "always", 72],
|
||||
"subject-case": [0],
|
||||
"body-max-line-length": [0],
|
||||
},
|
||||
};
|
||||
|
||||
export default konfiguration;
|
||||
@@ -0,0 +1,26 @@
|
||||
# Nur für die Entwicklung: PostgreSQL auf eigenem Port (5437), damit die Datenbanken
|
||||
# anderer Projekte frei bleiben. Niemals im Betrieb verwenden.
|
||||
|
||||
name: proxdeploy-dev
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: proxdeploy
|
||||
POSTGRES_PASSWORD: proxdeploy
|
||||
POSTGRES_DB: proxdeploy
|
||||
ports:
|
||||
- "127.0.0.1:${DEV_DB_PORT:-5437}:5432"
|
||||
volumes:
|
||||
- datenbank-dev:/var/lib/postgresql/data
|
||||
- ./scripts/testdatenbank.sql:/docker-entrypoint-initdb.d/testdatenbank.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U proxdeploy"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
datenbank-dev:
|
||||
@@ -1,27 +1,33 @@
|
||||
# #001 Projekt-Setup
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** chore, infrastruktur
|
||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
||||
**Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
|
||||
|
||||
## Ziel
|
||||
Lauffähiges Grundgerüst mit Projektorganisation, Design-Tokens und Prüfwerkzeugen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Git, `.gitignore`, CHANGELOG, DEVLOG, Issues, `docs/architektur.md`, `CLAUDE.md` mit Namensabbildung
|
||||
- [ ] Next.js 16, TypeScript strict, Tailwind 4 mit Tokens aus der Farbtabelle (dunkel + hell)
|
||||
- [ ] Prisma 7 + Docker-Compose für Entwicklung (Postgres), Vitest mit Test-DB, ESLint/Prettier
|
||||
- [ ] Versionen exakt pinnen und begründen; Build/Tests laufen in Docker (lokal kein Node)
|
||||
- [ ] Grundlayout: Seitenleiste mit Wortmarke, Server-Umschalter, Theme-Umschalter, `<Status>`-Komponente
|
||||
- [ ] Lokale Anmeldung (erster Admin aus `.env`), Sitzungs-Cookie, `/api/v1/gesundheit`
|
||||
- [ ] pino-Logger (JSON), `.env.example`
|
||||
- [x] Git, `.gitignore`, CHANGELOG, DEVLOG, Issues, `docs/architektur.md`, `CLAUDE.md` mit Namensabbildung
|
||||
- [x] Next.js 16, TypeScript strict, Tailwind 4 mit Tokens aus der Farbtabelle (dunkel + hell)
|
||||
- [x] Prisma 7 + Docker-Compose für Entwicklung (Postgres auf 5437 mit Testdatenbank), Vitest mit Test-DB, ESLint/Prettier, commitlint + husky
|
||||
- [x] Versionen exakt pinnen und begründen (`CLAUDE.md`)
|
||||
- [x] Grundlayout: Seitenleiste mit Wortmarke, Theme-Umschalter, `<Status>`-Komponente, Handy-Schublade
|
||||
- [x] Lokale Anmeldung (erster Admin aus `.env`), Sitzungs-Cookie, `/api/v1/gesundheit`, `/api/v1/ich`
|
||||
- [x] pino-Logger (JSON), `.env.example`
|
||||
- [x] AES-256-GCM-Helfer (vorgezogen aus #002, weil der Start ihn prüft)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] `lint`, `typecheck`, `test`, `build` grün im Container
|
||||
- [ ] Anmeldung funktioniert im gebauten Stand; keine Secrets im Repo
|
||||
- [x] `lint`, `typecheck`, `format:pruefen`, `test` grün (60 Tests, davon 15 gegen die Datenbank)
|
||||
- [x] Anmeldung funktioniert im gebauten Stand; Playwright: 8 Abläufe auf 1440 px und 390 px grün
|
||||
- [x] Keine Secrets im Repo
|
||||
|
||||
## Abhängigkeiten
|
||||
–
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
–
|
||||
- Node läuft über nvm (nicht systemweit) – Docker ist für die Builds nicht nötig.
|
||||
- Server-Umschalter in der Seitenleiste folgt mit #002, wenn es Server gibt.
|
||||
- Navigationseinträge tragen `ab: "#NNN"` und erscheinen erst, wenn ihr Issue fertig ist.
|
||||
- Anmeldesperre: 10 Fehlversuche je Benutzername und Adresse in 15 min, im Speicher.
|
||||
- Das Protokoll ist per Trigger nur anhängbar; nur das Nullsetzen von `benutzerId` beim Löschen eines Benutzers ist erlaubt.
|
||||
|
||||
@@ -4,7 +4,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
||||
|
||||
| Nr. | Titel | Status | Meilenstein | Geschätzt | Tatsächlich |
|
||||
|---|---|---|---|---|---|
|
||||
| [#001](001-projekt-setup.md) | Projekt-Setup | offen | v0.1.0 | 4 h | – |
|
||||
| [#001](001-projekt-setup.md) | Projekt-Setup | erledigt | v0.1.0 | 4 h | 0 h 50 min |
|
||||
| [#002](002-verschluesselung-server.md) | Server-Verwaltung und Verschlüsselung | offen | v0.1.0 | 6 h | – |
|
||||
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | offen | v0.1.0 | 6 h | – |
|
||||
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | offen | v0.2.0 | 5 h | – |
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import prettier from "eslint-config-prettier/flat";
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
prettier,
|
||||
{
|
||||
rules: {
|
||||
// Zod wird in src/lib/validierung.ts auf deutsche Meldungen eingestellt.
|
||||
// Ein direkter Import aus "zod" umginge das, und die englischen
|
||||
// Standardsaetze landeten in der Oberflaeche.
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "zod",
|
||||
message:
|
||||
'Bitte `import { z } from "@/lib/validierung"` benutzen – dort sind die deutschen Meldungen eingestellt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Die Datei, die Zod einstellt, muss es selbst importieren duerfen.
|
||||
files: ["src/lib/validierung.ts"],
|
||||
rules: { "no-restricted-imports": "off" },
|
||||
},
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"next-env.d.ts",
|
||||
// Erzeugter Prisma-Client.
|
||||
"src/generated/**",
|
||||
"test-results/**",
|
||||
"playwright-report/**",
|
||||
"dist/**",
|
||||
"cli/**",
|
||||
]),
|
||||
]);
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/** Feste Sicherheitskopfzeilen; die CSP setzt src/proxy.ts je Anfrage mit Nonce. */
|
||||
const SICHERHEITS_KOPFZEILEN = [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "geolocation=(), microphone=(), camera=()" },
|
||||
];
|
||||
|
||||
const konfiguration: NextConfig = {
|
||||
// Schlankes Laufzeit-Abbild für Dockerfile und LXC-Installer.
|
||||
output: "standalone",
|
||||
// ssh2 bringt optionale native Teile mit, die der Bundler nicht anfassen soll.
|
||||
serverExternalPackages: ["ssh2", "pg-boss", "pino"],
|
||||
async headers() {
|
||||
return [{ source: "/:pfad*", headers: SICHERHEITS_KOPFZEILEN }];
|
||||
},
|
||||
};
|
||||
|
||||
export default konfiguration;
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "proxdeploy",
|
||||
"version": "0.1.0-dev",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI",
|
||||
"packageManager": "pnpm@12.6.0",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:up": "docker compose -f docker-compose.dev.yml up -d --wait",
|
||||
"dev": "next dev",
|
||||
"format": "prettier --write .",
|
||||
"format:pruefen": "prettier --check .",
|
||||
"lint": "eslint",
|
||||
"postinstall": "prisma generate",
|
||||
"prepare": "husky",
|
||||
"start": "next start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource-variable/space-grotesk": "^5.3.0",
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"@prisma/adapter-pg": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "1.47.0",
|
||||
"next": "16.4.0",
|
||||
"pg": "^8.23.0",
|
||||
"pino": "10.4.0",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.3",
|
||||
"@commitlint/config-conventional": "^21.2.3",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24.13.6",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"esbuild": "^0.27.0",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.5.1",
|
||||
"prettier": "^3.9.9",
|
||||
"prisma": "7.10.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.13",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,mts,mjs}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,css,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Ende-zu-Ende-Abläufe – immer gegen den **gebauten** Stand (`pnpm build && pnpm start`), weil
|
||||
* `next dev` hier nicht verlässlich hydriert und ein Screenshot dann nichts beweist.
|
||||
*/
|
||||
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3310";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
forbidOnly: Boolean(process.env["CI"]),
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: APP_URL,
|
||||
trace: "retain-on-failure",
|
||||
locale: "de-DE",
|
||||
timezoneId: "Europe/Berlin",
|
||||
},
|
||||
projects: [
|
||||
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } } },
|
||||
// 390 px ist die Abnahmebreite fürs Handy (kein horizontales Scrollen).
|
||||
{ name: "handy", use: { ...devices["Pixel 7"], viewport: { width: 390, height: 844 } } },
|
||||
],
|
||||
});
|
||||
Generated
+7489
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# pnpm 12 fuehrt Build-Skripte von Abhaengigkeiten nur nach ausdruecklicher Freigabe aus.
|
||||
# Nur diese vier brauchen eines; alles andere laeuft ohne. Erweitern mit `pnpm approve-builds`.
|
||||
allowBuilds:
|
||||
"@prisma/engines": true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
unrs-resolver: true
|
||||
@@ -0,0 +1,6 @@
|
||||
// Tailwind 4 wird ueber sein PostCSS-Plugin eingebunden; eine tailwind.config.js gibt es nicht mehr.
|
||||
const konfiguration = {
|
||||
plugins: { "@tailwindcss/postcss": {} },
|
||||
};
|
||||
|
||||
export default konfiguration;
|
||||
@@ -0,0 +1,8 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: { path: "prisma/migrations" },
|
||||
datasource: { url: process.env["DATABASE_URL"] },
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'OPERATOR');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Quelle" AS ENUM ('WEB', 'CLI', 'API', 'SYSTEM');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Benutzer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"benutzername" TEXT NOT NULL,
|
||||
"anzeigename" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"passwortHash" TEXT,
|
||||
"oidcSubjekt" TEXT,
|
||||
"rolle" "Rolle" NOT NULL DEFAULT 'OPERATOR',
|
||||
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||
"standards" JSONB,
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Benutzer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Protokoll" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"zeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"benutzerId" TEXT,
|
||||
"quelle" "Quelle" NOT NULL,
|
||||
"aktion" TEXT NOT NULL,
|
||||
"ziel" TEXT,
|
||||
"details" JSONB,
|
||||
"ip" TEXT,
|
||||
|
||||
CONSTRAINT "Protokoll_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Einstellung" (
|
||||
"schluessel" TEXT NOT NULL,
|
||||
"wert" JSONB NOT NULL,
|
||||
"geaendertAm" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Einstellung_pkey" PRIMARY KEY ("schluessel")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Benutzer_benutzername_key" ON "Benutzer"("benutzername");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Benutzer_oidcSubjekt_key" ON "Benutzer"("oidcSubjekt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Protokoll_zeit_idx" ON "Protokoll"("zeit");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Protokoll_benutzerId_zeit_idx" ON "Protokoll"("benutzerId", "zeit");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Protokoll" ADD CONSTRAINT "Protokoll_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Regeln, die auch gelten, wenn jemand an der Service-Schicht vorbei schreibt.
|
||||
|
||||
-- Das Audit-Log ist nur anhängbar. Eine Ausnahme braucht es nicht: auch das Löschen
|
||||
-- eines Benutzers setzt benutzerId per FK auf NULL, und das ist ein UPDATE – das
|
||||
-- erlaubt der Trigger nur für genau diese Spalte.
|
||||
CREATE FUNCTION "protokoll_nur_anhaengen"() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE'
|
||||
AND NEW."benutzerId" IS NULL
|
||||
AND (NEW.id, NEW.zeit, NEW.quelle, NEW.aktion, NEW.ziel, NEW.details, NEW.ip)
|
||||
IS NOT DISTINCT FROM (OLD.id, OLD.zeit, OLD.quelle, OLD.aktion, OLD.ziel, OLD.details, OLD.ip) THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION 'Das Protokoll ist nur anhängbar (%).', TG_OP USING ERRCODE = 'P0001';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "protokoll_nur_anhaengen"
|
||||
BEFORE UPDATE OR DELETE ON "Protokoll"
|
||||
FOR EACH ROW EXECUTE FUNCTION "protokoll_nur_anhaengen"();
|
||||
|
||||
ALTER TABLE "Benutzer"
|
||||
ADD CONSTRAINT "benutzer_benutzername_format"
|
||||
CHECK ("benutzername" ~ '^[a-z0-9][a-z0-9._-]{1,62}$');
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,63 @@
|
||||
// Datenmodell von ProxDeploy. Namensabbildung zur Vorgabe: siehe CLAUDE.md.
|
||||
// Modelle kommen mit dem Issue dazu, das sie braucht – der Plan in docs/architektur.md
|
||||
// zeigt das Zielbild.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum Rolle {
|
||||
ADMIN
|
||||
OPERATOR
|
||||
}
|
||||
|
||||
enum Quelle {
|
||||
WEB
|
||||
CLI
|
||||
API
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
model Benutzer {
|
||||
id String @id @default(cuid())
|
||||
benutzername String @unique
|
||||
anzeigename String
|
||||
email String?
|
||||
/// argon2id; leer bei Benutzern, die sich nur per OIDC anmelden.
|
||||
passwortHash String?
|
||||
oidcSubjekt String? @unique
|
||||
rolle Rolle @default(OPERATOR)
|
||||
aktiv Boolean @default(true)
|
||||
/// Persönliche Standardwerte im Assistenten.
|
||||
standards Json?
|
||||
erstelltAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
protokoll Protokoll[]
|
||||
}
|
||||
|
||||
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
|
||||
model Protokoll {
|
||||
id BigInt @id @default(autoincrement())
|
||||
zeit DateTime @default(now())
|
||||
benutzerId String?
|
||||
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||
quelle Quelle
|
||||
aktion String
|
||||
ziel String?
|
||||
details Json?
|
||||
ip String?
|
||||
|
||||
@@index([zeit])
|
||||
@@index([benutzerId, zeit])
|
||||
}
|
||||
|
||||
model Einstellung {
|
||||
schluessel String @id
|
||||
wert Json
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<path d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z" stroke="#E6EDF3" stroke-width="3.5" stroke-linejoin="round"/>
|
||||
<path d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 47 H40" stroke="#2DD4BF" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1 @@
|
||||
CREATE DATABASE proxdeploy_test OWNER proxdeploy;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Seitenleiste } from "@/components/seitenleiste";
|
||||
import { NAVIGATION } from "@/lib/navigation";
|
||||
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
||||
import { versionText } from "@/lib/version";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||
|
||||
/**
|
||||
* Hülle aller angemeldeten Seiten. Sie ist das **zweite** Netz – jede Seite prüft ihr Recht
|
||||
* selbst, weil Next dieses Layout bei Navigation innerhalb der Gruppe nicht neu rendert.
|
||||
*/
|
||||
export default async function AppHuelle({ children }: { children: React.ReactNode }) {
|
||||
const benutzer = await aktuellerBenutzer();
|
||||
if (!benutzer) redirect("/anmelden");
|
||||
|
||||
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
|
||||
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
|
||||
|
||||
return (
|
||||
<div className="lg:flex">
|
||||
<Seitenleiste
|
||||
eintraege={eintraege}
|
||||
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
|
||||
version={versionText()}
|
||||
theme={theme}
|
||||
serverUmschalter={null}
|
||||
/>
|
||||
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
|
||||
<div className="mx-auto max-w-6xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Karte } from "@/components/karte";
|
||||
import { Status } from "@/components/status";
|
||||
import { seiteSchuetzen } from "@/server/auth/zugang";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
export default async function Uebersicht() {
|
||||
const benutzer = await seiteSchuetzen("lesen", "/");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<p className="text-muted text-sm">Willkommen, {benutzer.anzeigename}</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Übersicht</h1>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Karte titel="Server">
|
||||
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
|
||||
<p className="text-muted mt-2 text-sm">
|
||||
Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist.
|
||||
</p>
|
||||
</Karte>
|
||||
<Karte titel="Letzte Deployments">
|
||||
<Status art="offen">Noch keine Deployments.</Status>
|
||||
</Karte>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Knopf } from "@/components/knopf";
|
||||
import { Status } from "@/components/status";
|
||||
|
||||
export function Anmeldeformular({ ziel }: { ziel: string }) {
|
||||
const router = useRouter();
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [sendet, setSendet] = useState(false);
|
||||
|
||||
async function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
setSendet(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const antwort = await fetch("/api/v1/auth/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ benutzername: daten.get("benutzername"), passwort: daten.get("passwort") }),
|
||||
});
|
||||
if (!antwort.ok) {
|
||||
const inhalt = (await antwort.json().catch(() => null)) as {
|
||||
fehler?: { nachricht?: string; hinweis?: string };
|
||||
} | null;
|
||||
setFehler(
|
||||
[inhalt?.fehler?.nachricht ?? "Anmeldung fehlgeschlagen.", inhalt?.fehler?.hinweis]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
router.replace(ziel);
|
||||
router.refresh();
|
||||
} catch {
|
||||
setFehler("ProxDeploy ist nicht erreichbar.");
|
||||
} finally {
|
||||
setSendet(false);
|
||||
}
|
||||
}
|
||||
|
||||
const feld =
|
||||
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent";
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={absenden}
|
||||
className="bg-panel border-linie rounded-karte flex flex-col gap-4 border p-6"
|
||||
noValidate
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="benutzername" className="text-sm font-medium">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
id="benutzername"
|
||||
name="benutzername"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
className={feld}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="passwort" className="text-sm font-medium">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className={feld}
|
||||
/>
|
||||
</div>
|
||||
<div aria-live="polite" className="min-h-5 text-sm">
|
||||
{fehler ? <Status art="fehler">{fehler}</Status> : null}
|
||||
</div>
|
||||
<Knopf type="submit" disabled={sendet}>
|
||||
{sendet ? "Anmelden …" : "Anmelden"}
|
||||
</Knopf>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||
import { aktuellerBenutzer } from "@/server/auth/zugang";
|
||||
import { Anmeldeformular } from "./anmeldeformular";
|
||||
|
||||
export const metadata: Metadata = { title: "Anmelden" };
|
||||
|
||||
/** Nur relative Ziele innerhalb der Anwendung – sonst wäre `?ziel=` eine offene Weiterleitung. */
|
||||
function sicheresZiel(ziel: string | string[] | undefined): string {
|
||||
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
|
||||
}
|
||||
|
||||
export default async function Anmelden({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[]>>;
|
||||
}) {
|
||||
const ziel = sicheresZiel((await searchParams)["ziel"]);
|
||||
if (await aktuellerBenutzer()) redirect(ziel);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<Symbol className="h-14 w-14" />
|
||||
<h1 className="text-2xl">
|
||||
<Wortmarke blinkt />
|
||||
</h1>
|
||||
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
|
||||
</div>
|
||||
<Anmeldeformular ziel={ziel} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse } from "@/lib/adressen";
|
||||
import { SITZUNG_COOKIE } from "@/lib/sitzung";
|
||||
import { api } from "@/server/api";
|
||||
import { anfrageAufrufer } from "@/server/auth/zugang";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/** Öffentlich: Abmelden muss auch mit abgelaufener Sitzung gehen. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const aufrufer = await anfrageAufrufer(anfrage);
|
||||
if (aufrufer) {
|
||||
await protokollieren({
|
||||
benutzerId: aufrufer.benutzer.id,
|
||||
quelle: aufrufer.quelle,
|
||||
aktion: "Abgemeldet",
|
||||
ip: aufruferAdresse(anfrage.headers),
|
||||
});
|
||||
}
|
||||
const antwort = NextResponse.json({ ok: true });
|
||||
antwort.cookies.delete(SITZUNG_COOKIE);
|
||||
return antwort;
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { aufruferAdresse, istHttps } from "@/lib/adressen";
|
||||
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { api, jsonLesen } from "@/server/api";
|
||||
import { mitPasswortAnmelden } from "@/server/services/anmeldung";
|
||||
|
||||
const Anmeldung = z.object({
|
||||
benutzername: z.string().trim().min(1, "Benutzername fehlt.").max(64),
|
||||
passwort: z.string().min(1, "Passwort fehlt.").max(1024),
|
||||
});
|
||||
|
||||
/** Öffentlich (Proxy-Ausnahme) – sonst könnte sich niemand anmelden. */
|
||||
export const POST = api(async (anfrage: Request) => {
|
||||
const eingabe = Anmeldung.parse(await jsonLesen(anfrage));
|
||||
const benutzer = await mitPasswortAnmelden(
|
||||
eingabe.benutzername,
|
||||
eingabe.passwort,
|
||||
aufruferAdresse(anfrage.headers),
|
||||
);
|
||||
|
||||
const antwort = NextResponse.json({ benutzername: benutzer.benutzername, rolle: benutzer.rolle });
|
||||
antwort.cookies.set(
|
||||
SITZUNG_COOKIE,
|
||||
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "passwort" }),
|
||||
{
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: istHttps(anfrage.headers, anfrage.url),
|
||||
path: "/",
|
||||
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
|
||||
},
|
||||
);
|
||||
return antwort;
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { VERSION } from "@/lib/version";
|
||||
|
||||
/** Healthcheck für Docker und Reverse-Proxy. Öffentlich, verrät nur die Version. */
|
||||
export async function GET(): Promise<Response> {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ status: "ok", version: VERSION });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "datenbank_nicht_erreichbar", version: VERSION }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { VERSION } from "@/lib/version";
|
||||
import { api } from "@/server/api";
|
||||
import { RECHTE, darf, type Recht } from "@/server/auth/rechte";
|
||||
import { rechtErforderlich } from "@/server/auth/zugang";
|
||||
|
||||
/** Wer bin ich, was darf ich – für die CLI (`proxdeploy login`) und die GUI. */
|
||||
export const GET = api(async (anfrage: Request) => {
|
||||
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
|
||||
const rechte = (Object.keys(RECHTE) as Recht[]).filter((recht) => darf(benutzer.rolle, recht));
|
||||
return NextResponse.json({
|
||||
benutzername: benutzer.benutzername,
|
||||
anzeigename: benutzer.anzeigename,
|
||||
rolle: benutzer.rolle,
|
||||
rechte,
|
||||
version: VERSION,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Farben von ProxDeploy – die einzige Stelle mit Hexwerten. Komponenten greifen nur über die
|
||||
* Tailwind-Klassen (bg-panel, text-muted, border-linie …) oder var(--…) darauf zu.
|
||||
*
|
||||
* Dunkel ist der Standard. Das helle Theme setzt dieselben Namen neu, damit keine Komponente
|
||||
* zwei Varianten kennen muss. Amber und Rot sind auf Hell abgedunkelt: die Originaltöne
|
||||
* erreichen auf #F4F6F8 keine 4,5:1 (tests/kontrast.test.ts prüft beide Themes).
|
||||
*/
|
||||
:root,
|
||||
[data-theme="dunkel"] {
|
||||
--farbe-grund: #11161c; /* Ink */
|
||||
--farbe-panel: #1a2129;
|
||||
--farbe-panel-hoch: #212a34; /* Hover, Eingabefelder */
|
||||
--farbe-linie: #2a333d;
|
||||
--farbe-muted: #8b98a5;
|
||||
--farbe-text: #e6edf3; /* Paper */
|
||||
--farbe-akzent: #2dd4bf; /* Teal */
|
||||
--farbe-akzent-text: #11161c; /* Text auf Akzentfläche */
|
||||
--farbe-laeuft: #f5b841; /* Amber */
|
||||
--farbe-fehler: #f87171;
|
||||
--farbe-fokus: #2dd4bf;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme="hell"] {
|
||||
--farbe-grund: #f4f6f8;
|
||||
--farbe-panel: #ffffff;
|
||||
--farbe-panel-hoch: #eef1f4;
|
||||
--farbe-linie: #d5dbe1;
|
||||
--farbe-muted: #4f5b66;
|
||||
--farbe-text: #11161c;
|
||||
--farbe-akzent: #0f766e; /* Teal dunkel */
|
||||
--farbe-akzent-text: #ffffff;
|
||||
--farbe-laeuft: #8a5a00;
|
||||
--farbe-fehler: #b91c1c;
|
||||
--farbe-fokus: #0f766e;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-grund: var(--farbe-grund);
|
||||
--color-panel: var(--farbe-panel);
|
||||
--color-panel-hoch: var(--farbe-panel-hoch);
|
||||
--color-linie: var(--farbe-linie);
|
||||
--color-muted: var(--farbe-muted);
|
||||
--color-text: var(--farbe-text);
|
||||
--color-akzent: var(--farbe-akzent);
|
||||
--color-akzent-text: var(--farbe-akzent-text);
|
||||
--color-laeuft: var(--farbe-laeuft);
|
||||
--color-fehler: var(--farbe-fehler);
|
||||
|
||||
--font-sans: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", monospace;
|
||||
|
||||
--radius-karte: 14px;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--farbe-grund);
|
||||
color: var(--farbe-text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Fokus immer sichtbar, aber nur bei Tastaturbedienung. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--farbe-fokus);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes pd-cursor {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.pd-cursor-blinkt {
|
||||
animation: pd-cursor 1.1s steps(1) infinite;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { Status } from "@/components/status";
|
||||
|
||||
/** Ziel von `seiteSchuetzen`, wenn die Rolle nicht reicht. Zeigt nichts außer dem Hinweis. */
|
||||
export default function KeinRecht() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-4 px-4">
|
||||
<Status art="fehler" className="text-lg font-semibold">
|
||||
Dafür fehlt die Berechtigung.
|
||||
</Status>
|
||||
<p className="text-muted">Ein Administrator kann die Rolle ändern.</p>
|
||||
<Link href="/" className="text-akzent underline underline-offset-4">
|
||||
Zur Übersicht
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import "@fontsource-variable/space-grotesk";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
|
||||
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
|
||||
icons: { icon: "/brand/symbol.svg" },
|
||||
};
|
||||
|
||||
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
|
||||
export const viewport: Viewport = { themeColor: "#11161C", width: "device-width", initialScale: 1 };
|
||||
|
||||
export default async function Wurzel({ children }: { children: React.ReactNode }) {
|
||||
// Das Theme steht im Cookie, damit der Server gleich richtig rendert und nichts aufblitzt.
|
||||
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
|
||||
return (
|
||||
<html lang="de" data-theme={theme}>
|
||||
<body className="bg-grund text-text min-h-dvh">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Panel-Karte: 1-px-Rahmen in Linienfarbe, Radius 14 px, keine Schatten. */
|
||||
export function Karte({
|
||||
titel,
|
||||
aktion,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
titel?: React.ReactNode;
|
||||
aktion?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={clsx("bg-panel border-linie rounded-karte border", className)}>
|
||||
{titel ? (
|
||||
<header className="border-linie flex items-center justify-between gap-3 border-b px-5 py-3.5">
|
||||
<h2 className="text-base font-semibold">{titel}</h2>
|
||||
{aktion}
|
||||
</header>
|
||||
) : null}
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
type Variante = "primaer" | "sekundaer" | "gefahr";
|
||||
|
||||
const VARIANTEN: Record<Variante, string> = {
|
||||
primaer: "bg-akzent text-akzent-text hover:opacity-90",
|
||||
sekundaer: "border border-linie bg-panel-hoch text-text hover:border-muted",
|
||||
gefahr: "border border-fehler text-fehler hover:bg-fehler/10",
|
||||
};
|
||||
|
||||
/** Ein echter `<button>`; 40 px hoch, am Handy 44 px (Tippfläche). */
|
||||
export function Knopf({
|
||||
variante = "primaer",
|
||||
className,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variante?: Variante }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"inline-flex h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition sm:h-10",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
VARIANTEN[variante],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
BookOpen,
|
||||
FileStack,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
Rocket,
|
||||
Server,
|
||||
Settings,
|
||||
Layers,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { istAktiv, type NavigationsEintrag } from "@/lib/navigation";
|
||||
import type { Theme } from "@/lib/theme";
|
||||
import { Symbol, Wortmarke } from "@/components/wortmarke";
|
||||
import { ThemeUmschalter } from "@/components/theme-umschalter";
|
||||
|
||||
const SYMBOLE: Record<
|
||||
NavigationsEintrag["symbol"],
|
||||
React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>
|
||||
> = {
|
||||
uebersicht: LayoutDashboard,
|
||||
ausrollen: Rocket,
|
||||
verlauf: History,
|
||||
templates: Layers,
|
||||
vorlagen: FileStack,
|
||||
katalog: BookOpen,
|
||||
server: Server,
|
||||
einstellungen: Settings,
|
||||
};
|
||||
|
||||
type Eigenschaften = {
|
||||
eintraege: readonly NavigationsEintrag[];
|
||||
benutzer: { anzeigename: string; rolle: string };
|
||||
version: string;
|
||||
theme: Theme;
|
||||
serverUmschalter: React.ReactNode;
|
||||
};
|
||||
|
||||
export function Seitenleiste({ eintraege, benutzer, version, theme, serverUmschalter }: Eigenschaften) {
|
||||
const pfad = usePathname();
|
||||
const router = useRouter();
|
||||
const [offen, setOffen] = useState(false);
|
||||
|
||||
async function abmelden() {
|
||||
await fetch("/api/v1/auth/abmelden", { method: "POST" });
|
||||
router.replace("/anmelden");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const inhalt = (
|
||||
<div className="flex h-full flex-col gap-6 p-4">
|
||||
<Link href="/" className="flex items-center gap-2.5 px-2 pt-1" onClick={() => setOffen(false)}>
|
||||
<Symbol className="h-8 w-8 shrink-0" />
|
||||
<Wortmarke className="text-lg" />
|
||||
</Link>
|
||||
|
||||
{serverUmschalter}
|
||||
|
||||
<nav aria-label="Hauptnavigation" className="flex-1">
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{eintraege.map((eintrag) => {
|
||||
const Icon = SYMBOLE[eintrag.symbol];
|
||||
const aktiv = istAktiv(eintrag.pfad, pfad);
|
||||
return (
|
||||
<li key={eintrag.pfad}>
|
||||
<Link
|
||||
href={eintrag.pfad}
|
||||
onClick={() => setOffen(false)}
|
||||
aria-current={aktiv ? "page" : undefined}
|
||||
className={clsx(
|
||||
"flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition sm:h-10",
|
||||
aktiv ? "bg-panel-hoch text-text" : "text-muted hover:text-text hover:bg-panel-hoch/60",
|
||||
)}
|
||||
>
|
||||
<span className={aktiv ? "text-akzent" : undefined}>
|
||||
<Icon size={18} aria-hidden={true} />
|
||||
</span>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="border-linie flex flex-col gap-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{benutzer.anzeigename}</p>
|
||||
<p className="text-muted text-xs">{benutzer.rolle === "ADMIN" ? "Administrator" : "Operator"}</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ThemeUmschalter anfang={theme} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={abmelden}
|
||||
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
aria-label="Abmelden"
|
||||
title="Abmelden"
|
||||
>
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted px-2 font-mono text-xs">{version}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Handy: Kopfleiste mit Menüknopf, Navigation als Schublade. */}
|
||||
<div className="border-linie bg-panel sticky top-0 z-30 flex h-14 items-center justify-between border-b px-4 lg:hidden">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Symbol className="h-7 w-7" />
|
||||
<Wortmarke />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-lg"
|
||||
aria-label="Navigation öffnen"
|
||||
aria-expanded={offen}
|
||||
aria-controls="seitenleiste"
|
||||
>
|
||||
<Menu size={22} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{offen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
aria-hidden="true"
|
||||
onClick={() => setOffen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
id="seitenleiste"
|
||||
className={clsx(
|
||||
"border-linie bg-panel fixed inset-y-0 left-0 z-50 w-72 border-r transition-transform lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0",
|
||||
offen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(false)}
|
||||
className="absolute top-3 right-3 inline-flex h-11 w-11 items-center justify-center rounded-lg lg:hidden"
|
||||
aria-label="Navigation schließen"
|
||||
>
|
||||
<X size={20} aria-hidden="true" />
|
||||
</button>
|
||||
{inhalt}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
|
||||
* Dieselben Symbole wie in der CLI-Ausgabe.
|
||||
*/
|
||||
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
|
||||
|
||||
export const STATUS_SYMBOL: Record<StatusArt, string> = {
|
||||
erledigt: "✓",
|
||||
laeuft: "◐",
|
||||
fehler: "✗",
|
||||
offen: "○",
|
||||
};
|
||||
|
||||
const STATUS_FARBE: Record<StatusArt, string> = {
|
||||
erledigt: "text-akzent",
|
||||
laeuft: "text-laeuft",
|
||||
fehler: "text-fehler",
|
||||
offen: "text-muted",
|
||||
};
|
||||
|
||||
export function Status({
|
||||
art,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
art: StatusArt;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
|
||||
<span aria-hidden="true" className="font-mono">
|
||||
{STATUS_SYMBOL[art]}
|
||||
</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_COOKIE, type Theme } from "@/lib/theme";
|
||||
|
||||
export function ThemeUmschalter({ anfang }: { anfang: Theme }) {
|
||||
const [theme, setTheme] = useState<Theme>(anfang);
|
||||
const naechstes: Theme = theme === "dunkel" ? "hell" : "dunkel";
|
||||
|
||||
function umschalten() {
|
||||
document.documentElement.dataset["theme"] = naechstes;
|
||||
document.cookie = `${THEME_COOKIE}=${naechstes}; path=/; max-age=31536000; samesite=lax`;
|
||||
setTheme(naechstes);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={umschalten}
|
||||
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
aria-label={naechstes === "hell" ? "Helles Theme einschalten" : "Dunkles Theme einschalten"}
|
||||
title={naechstes === "hell" ? "Helles Theme" : "Dunkles Theme"}
|
||||
>
|
||||
{theme === "dunkel" ? <Sun size={18} aria-hidden="true" /> : <Moon size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Wortmarke nach Branding-Vorgabe: „Prox“ normal, „Deploy“ fett in Akzentfarbe, dahinter ein
|
||||
* Block-Cursor. Die Schrift ist immer JetBrains Mono.
|
||||
*/
|
||||
export function Wortmarke({ className, blinkt = false }: { className?: string; blinkt?: boolean }) {
|
||||
return (
|
||||
<span className={clsx("font-mono tracking-tight whitespace-nowrap", className)}>
|
||||
<span className="text-text">Prox</span>
|
||||
<span className="text-akzent font-bold">Deploy</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx("bg-akzent ml-[0.12em] inline-block h-[0.95em] w-[0.5em] align-[-0.12em]", {
|
||||
"pd-cursor-blinkt": blinkt,
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Symbol „Launch-Node“ als Inline-SVG, damit es die Theme-Farben übernimmt. */
|
||||
export function Symbol({ className, titel }: { className?: string; titel?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
className={className}
|
||||
role={titel ? "img" : undefined}
|
||||
aria-hidden={titel ? undefined : true}
|
||||
>
|
||||
{titel ? <title>{titel}</title> : null}
|
||||
<path
|
||||
d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z"
|
||||
stroke="var(--farbe-text)"
|
||||
strokeWidth="3.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5"
|
||||
stroke="var(--farbe-akzent)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M24 47 H40" stroke="var(--farbe-akzent)" strokeWidth="4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
|
||||
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
|
||||
*/
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
|
||||
const { schluesselPruefen } = await import("@/lib/geheimnisse");
|
||||
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
|
||||
const { logger } = await import("@/lib/logger");
|
||||
try {
|
||||
schluesselPruefen();
|
||||
await ersterAdminFallsLeer();
|
||||
} catch (fehler) {
|
||||
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Adressen der Anwendung nach außen.
|
||||
*
|
||||
* Maßgeblich ist APP_URL, nicht die eingehende Anfrage: hinter dem Reverse-Proxy trägt die
|
||||
* Anfrage den internen Host und oft das falsche Schema. Die Herkunft der Anfrage dient nur als
|
||||
* Notnagel, damit die Anwendung ohne APP_URL nicht stehen bleibt.
|
||||
*/
|
||||
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
const basis = process.env["APP_URL"]?.trim();
|
||||
if (basis) {
|
||||
try {
|
||||
return new URL(pfad, basis);
|
||||
} catch {
|
||||
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
|
||||
}
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob die Verbindung des Browsers HTTPS ist. Hinter einem Reverse-Proxy (TRUST_PROXY=true) zählt
|
||||
* X-Forwarded-Proto, sonst das Schema der Anfrage. Davon hängt das `Secure`-Attribut des
|
||||
* Sitzungscookies ab: fest gesetzt, scheitert die Anmeldung über reines HTTP stillschweigend
|
||||
* (in ProxLedger 0.11.1 passiert).
|
||||
*/
|
||||
export function istHttps(kopfzeilen: Headers, anfrageUrl: string): boolean {
|
||||
if (process.env["TRUST_PROXY"] === "true") {
|
||||
const weitergeleitet = kopfzeilen.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
if (weitergeleitet) return weitergeleitet === "https";
|
||||
}
|
||||
return new URL(anfrageUrl).protocol === "https:";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse des Aufrufers für Protokoll und Anmeldesperre. Ohne vertrauten Proxy sind alle
|
||||
* Kopfzeilen vom Aufrufer frei wählbar, dann bleibt die Adresse lieber leer als erfunden.
|
||||
*/
|
||||
export function aufruferAdresse(kopfzeilen: Headers): string | null {
|
||||
if (process.env["TRUST_PROXY"] !== "true") return null;
|
||||
return kopfzeilen.get("x-forwarded-for")?.split(",")[0]?.trim() || kopfzeilen.get("x-real-ip");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Zugriff auf die Datenbank.
|
||||
*
|
||||
* Der Client wird erst beim ersten Zugriff gebaut. Dadurch laeuft `next build`
|
||||
* ohne gesetzte DATABASE_URL durch, obwohl Module den Import auf oberster Ebene
|
||||
* haben. In der Entwicklung liegt die Instanz an globalThis, damit Hot Reload
|
||||
* nicht bei jedem Speichern eine neue Verbindung aufbaut.
|
||||
*/
|
||||
function clientBauen(): PrismaClient {
|
||||
const url = process.env["DATABASE_URL"];
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||
}
|
||||
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||
}
|
||||
|
||||
const globalerZwischenspeicher = globalThis as typeof globalThis & {
|
||||
proxdeployPrisma?: PrismaClient;
|
||||
};
|
||||
|
||||
let instanz: PrismaClient | undefined;
|
||||
|
||||
function instanzHolen(): PrismaClient {
|
||||
if (process.env["NODE_ENV"] !== "production") {
|
||||
globalerZwischenspeicher.proxdeployPrisma ??= clientBauen();
|
||||
return globalerZwischenspeicher.proxdeployPrisma;
|
||||
}
|
||||
instanz ??= clientBauen();
|
||||
return instanz;
|
||||
}
|
||||
|
||||
export const prisma = new Proxy({} as PrismaClient, {
|
||||
get(_ziel, eigenschaft, empfaenger) {
|
||||
return Reflect.get(instanzHolen(), eigenschaft, empfaenger);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Verschlüsselt Geheimnisse, die ProxDeploy im Klartext wieder braucht: Proxmox-Token-Secrets,
|
||||
* SSH-Privatschlüssel, das OIDC-Client-Secret. Anmeldepasswörter und CLI-Tokens werden dagegen
|
||||
* gehasht und nie entschlüsselt.
|
||||
*
|
||||
* AES-256-GCM, weil es neben der Vertraulichkeit auch die Unverfälschtheit bezeugt: ein
|
||||
* veränderter Geheimtext wirft beim Entschlüsseln, statt still Unsinn zu liefern.
|
||||
*
|
||||
* Der Schlüssel kommt **direkt** aus `PROXDEPLOY_SCHLUESSEL` (32 Byte, base64) und wird nicht aus
|
||||
* einem Passwort abgeleitet: Er ist ohnehin zufällig erzeugt, und eine Ableitung hätte nur ein
|
||||
* festes Salz im Code gebraucht, ohne etwas zu schützen.
|
||||
*/
|
||||
const ALGORITHMUS = "aes-256-gcm";
|
||||
const IV_LAENGE = 12;
|
||||
const AUTH_TAG_LAENGE = 16;
|
||||
const SCHLUESSEL_LAENGE = 32;
|
||||
|
||||
/** Versionspräfix, damit ein späterer Schlüsseltausch alte Werte noch erkennt. */
|
||||
const FORMAT_VERSION = "v1";
|
||||
|
||||
export class GeheimnisFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "GeheimnisFehler";
|
||||
}
|
||||
}
|
||||
|
||||
let zwischenspeicher: { quelle: string; schluessel: Buffer } | undefined;
|
||||
|
||||
function schluesselHolen(): Buffer {
|
||||
const quelle = process.env["PROXDEPLOY_SCHLUESSEL"]?.trim() ?? "";
|
||||
if (zwischenspeicher?.quelle === quelle) return zwischenspeicher.schluessel;
|
||||
|
||||
const schluessel = Buffer.from(quelle, "base64");
|
||||
// Die tatsächliche Länge steht bewusst nicht in der Meldung – sie könnte in einem Log landen.
|
||||
if (schluessel.length !== SCHLUESSEL_LAENGE) {
|
||||
throw new GeheimnisFehler(
|
||||
"PROXDEPLOY_SCHLUESSEL fehlt oder ist kein base64-kodierter 32-Byte-Schlüssel. " +
|
||||
"Erzeugen mit: openssl rand -base64 32",
|
||||
);
|
||||
}
|
||||
zwischenspeicher = { quelle, schluessel };
|
||||
return schluessel;
|
||||
}
|
||||
|
||||
/** Prüft beim Start, ob der Schlüssel brauchbar ist, statt erst beim ersten Server-Zugriff. */
|
||||
export function schluesselPruefen(): void {
|
||||
schluesselHolen();
|
||||
}
|
||||
|
||||
export function verschluesseln(klartext: string): string {
|
||||
const schluessel = schluesselHolen();
|
||||
// Je Aufruf ein neuer IV. Derselbe IV zweimal mit demselben Schlüssel bricht GCM vollständig.
|
||||
const iv = randomBytes(IV_LAENGE);
|
||||
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
|
||||
// base64 enthält keinen Punkt, er taugt also als Trenner.
|
||||
return [
|
||||
FORMAT_VERSION,
|
||||
iv.toString("base64"),
|
||||
chiffre.getAuthTag().toString("base64"),
|
||||
geheim.toString("base64"),
|
||||
].join(".");
|
||||
}
|
||||
|
||||
export function entschluesseln(geheimtext: string): string {
|
||||
const teile = geheimtext.split(".");
|
||||
const [version, ivTeil, tagTeil, datenTeil] = teile;
|
||||
if (teile.length !== 4 || !version || !ivTeil || !tagTeil || datenTeil === undefined) {
|
||||
throw new GeheimnisFehler("Geheimtext hat ein unbekanntes Format.");
|
||||
}
|
||||
if (version !== FORMAT_VERSION) {
|
||||
throw new GeheimnisFehler(`Geheimtext trägt die unbekannte Version „${version}“.`);
|
||||
}
|
||||
const iv = Buffer.from(ivTeil, "base64");
|
||||
const authTag = Buffer.from(tagTeil, "base64");
|
||||
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
|
||||
throw new GeheimnisFehler("Geheimtext ist unvollständig: IV oder Auth-Tag haben die falsche Länge.");
|
||||
}
|
||||
|
||||
const schluessel = schluesselHolen();
|
||||
try {
|
||||
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||
entschluessler.setAuthTag(authTag);
|
||||
// `final` prüft das Auth-Tag und wirft bei verändertem Text oder falschem Schlüssel.
|
||||
return Buffer.concat([
|
||||
entschluessler.update(Buffer.from(datenTeil, "base64")),
|
||||
entschluessler.final(),
|
||||
]).toString("utf8");
|
||||
} catch (ursache) {
|
||||
throw new GeheimnisFehler(
|
||||
"Geheimtext ließ sich nicht entschlüsseln. Entweder wurde der Wert verändert, oder " +
|
||||
"PROXDEPLOY_SCHLUESSEL ist nicht mehr derselbe wie beim Verschlüsseln.",
|
||||
{ cause: ursache },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import pino from "pino";
|
||||
|
||||
/**
|
||||
* Strukturierte Logs (JSON auf stdout) für den Betrieb. Geheimnisse werden an den bekannten
|
||||
* Stellen geschwärzt – zusätzlich zur Regel, sie gar nicht erst in Log-Objekte zu geben.
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: process.env["LOG_LEVEL"] ?? "info",
|
||||
base: { dienst: process.env["PROXDEPLOY_DIENST"] ?? "app" },
|
||||
messageKey: "nachricht",
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
redact: {
|
||||
paths: [
|
||||
"passwort",
|
||||
"*.passwort",
|
||||
"tokenGeheimnis",
|
||||
"*.tokenGeheimnis",
|
||||
"sshPrivat",
|
||||
"*.sshPrivat",
|
||||
"authorization",
|
||||
"*.authorization",
|
||||
"headers.cookie",
|
||||
],
|
||||
censor: "[geschwärzt]",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Navigation der Seitenleiste. Jeder Eintrag nennt sein Recht als Schlüssel aus RECHTE
|
||||
* (`src/server/auth/rechte.ts`); die Hülle blendet aus, was die Rolle nicht darf.
|
||||
* Hier nur Text und Pfade, damit Client-Komponenten es importieren dürfen.
|
||||
*/
|
||||
export type NavigationsEintrag = {
|
||||
pfad: string;
|
||||
titel: string;
|
||||
symbol:
|
||||
| "uebersicht"
|
||||
| "ausrollen"
|
||||
| "verlauf"
|
||||
| "templates"
|
||||
| "vorlagen"
|
||||
| "katalog"
|
||||
| "server"
|
||||
| "einstellungen";
|
||||
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
|
||||
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
|
||||
ab?: string;
|
||||
};
|
||||
|
||||
export const NAVIGATION: readonly NavigationsEintrag[] = [
|
||||
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
|
||||
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
|
||||
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
|
||||
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
|
||||
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
|
||||
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
|
||||
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
|
||||
{
|
||||
pfad: "/einstellungen",
|
||||
titel: "Einstellungen",
|
||||
symbol: "einstellungen",
|
||||
recht: "eigeneZugaenge",
|
||||
ab: "#015",
|
||||
},
|
||||
];
|
||||
|
||||
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
|
||||
export function istAktiv(eintrag: string, pfad: string): boolean {
|
||||
if (eintrag === "/") return pfad === "/";
|
||||
return pfad === eintrag || pfad.startsWith(`${eintrag}/`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
|
||||
/**
|
||||
* Anmeldepasswörter: argon2id mit den Vorgaben der Bibliothek (OWASP-konform).
|
||||
* Wird nie entschlüsselt – das unterscheidet es von `geheimnisse.ts`.
|
||||
*/
|
||||
export const PASSWORT_MINDESTLAENGE = 12;
|
||||
|
||||
export async function passwortHashen(passwort: string): Promise<string> {
|
||||
return await hash(passwort);
|
||||
}
|
||||
|
||||
export async function passwortPruefen(passwortHash: string, passwort: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwortHash, passwort);
|
||||
} catch {
|
||||
// Ein kaputter Hash in der Datenbank ist kein Grund, die Anmeldung abstürzen zu lassen.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fester Hash für den Fall „Benutzer gibt es nicht“: so dauert eine Anmeldung mit
|
||||
* unbekanntem Namen genauso lange wie eine mit falschem Passwort, und die Antwortzeit verrät
|
||||
* nicht, welche Benutzernamen existieren.
|
||||
*/
|
||||
let attrappe: Promise<string> | undefined;
|
||||
export async function attrappenPruefung(passwort: string): Promise<false> {
|
||||
attrappe ??= hash("proxdeploy-attrappe-ohne-bedeutung");
|
||||
await passwortPruefen(await attrappe, passwort);
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Das signierte Sitzungscookie der Web-GUI.
|
||||
*
|
||||
* Framework-frei, damit es ohne Next und ohne Datenbank testbar ist. Im Token steht nur, wer
|
||||
* angemeldet ist und wie. Rolle und Aktiv-Status stehen bewusst **nicht** darin – sie werden je
|
||||
* Anfrage aus der Datenbank gelesen, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
|
||||
*/
|
||||
export const SITZUNG_COOKIE = "pd_sitzung";
|
||||
|
||||
/** Ein Arbeitstag. */
|
||||
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
|
||||
|
||||
export type SitzungsHerkunft = "passwort" | "oidc";
|
||||
|
||||
export type Sitzung = {
|
||||
benutzerId: string;
|
||||
herkunft: SitzungsHerkunft;
|
||||
ausgestelltAm: number;
|
||||
};
|
||||
|
||||
const AUSSTELLER = "proxdeploy";
|
||||
const ZIELGRUPPE = "proxdeploy-web";
|
||||
const ALGORITHMUS = "HS256";
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
export class SitzungsFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "SitzungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
let zwischenspeicher: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||
|
||||
function schluesselHolen(): Uint8Array {
|
||||
const quelle = process.env["SITZUNG_GEHEIMNIS"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
throw new SitzungsFehler(
|
||||
`SITZUNG_GEHEIMNIS fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (zwischenspeicher?.quelle !== quelle) {
|
||||
zwischenspeicher = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
||||
}
|
||||
return zwischenspeicher.schluessel;
|
||||
}
|
||||
|
||||
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
||||
return await new SignJWT({ herkunft: daten.herkunft })
|
||||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||
.setSubject(daten.benutzerId)
|
||||
.setIssuer(AUSSTELLER)
|
||||
.setAudience(ZIELGRUPPE)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
||||
.sign(schluesselHolen());
|
||||
}
|
||||
|
||||
/** Gibt undefined zurück, wenn das Token fehlt, abgelaufen, verfälscht oder fremd ist. */
|
||||
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
||||
// Vor dem `try`: ein fehlendes Geheimnis ist ein Konfigurationsfehler, kein „nicht angemeldet“.
|
||||
const schluessel = schluesselHolen();
|
||||
if (!jwt) return undefined;
|
||||
try {
|
||||
const { payload } = await jwtVerify(jwt, schluessel, {
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: AUSSTELLER,
|
||||
audience: ZIELGRUPPE,
|
||||
});
|
||||
const { sub, iat, herkunft } = payload;
|
||||
if (
|
||||
typeof sub !== "string" ||
|
||||
sub.length === 0 ||
|
||||
typeof iat !== "number" ||
|
||||
(herkunft !== "passwort" && herkunft !== "oidc")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { benutzerId: sub, herkunft, ausgestelltAm: iat };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const THEME_COOKIE = "pd_theme";
|
||||
export type Theme = "dunkel" | "hell";
|
||||
|
||||
/** Dunkel ist der Standard; alles Unbekannte im Cookie wird zu dunkel. */
|
||||
export function themeAusCookie(wert: string | undefined): Theme {
|
||||
return wert === "hell" ? "hell" : "dunkel";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod mit deutschen Standardmeldungen.
|
||||
*
|
||||
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
|
||||
* englische Saetze heraus („Invalid input: expected string“), und die landen
|
||||
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
|
||||
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
|
||||
* ab, woran niemand gedacht hat.
|
||||
*
|
||||
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
|
||||
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
|
||||
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
|
||||
*/
|
||||
z.config(z.locales.de());
|
||||
|
||||
export { z };
|
||||
|
||||
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
|
||||
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
|
||||
export { ZodError } from "zod";
|
||||
export type { ZodIssue, ZodType } from "zod";
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
|
||||
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
|
||||
*/
|
||||
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev";
|
||||
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
|
||||
|
||||
export function versionText(): string {
|
||||
return GIT_SHA ? `v${VERSION} · ${GIT_SHA}` : `v${VERSION}`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||
import { SITZUNG_COOKIE } from "@/lib/sitzung";
|
||||
|
||||
/**
|
||||
* Next 16: die frühere middleware.ts. Sie setzt die Content-Security-Policy mit Nonce und ist
|
||||
* ein billiges Tor: ohne Sitzungscookie geht es zur Anmeldung. **Geprüft wird das Cookie hier
|
||||
* nicht** – das tun Seiten und Route Handler selbst gegen die Datenbank.
|
||||
*/
|
||||
export const OEFFENTLICH: readonly string[] = [
|
||||
"/anmelden",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/gesundheit",
|
||||
"/brand",
|
||||
"/manifest.webmanifest",
|
||||
];
|
||||
|
||||
export function istOeffentlich(pfad: string): boolean {
|
||||
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Im Entwicklungsmodus braucht React `unsafe-eval` und Fast Refresh `ws:` – ohne beides hydriert
|
||||
* die Seite nicht. Der gebaute Stand bekommt die strenge Fassung (tests/sicherheitsrichtlinie).
|
||||
*/
|
||||
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
|
||||
return [
|
||||
`default-src 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
`style-src 'self' 'unsafe-inline'`,
|
||||
`img-src 'self' data:`,
|
||||
`font-src 'self'`,
|
||||
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
|
||||
`frame-ancestors 'none'`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`object-src 'none'`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export function proxy(anfrage: NextRequest): NextResponse {
|
||||
const pfad = anfrage.nextUrl.pathname;
|
||||
// Die CLI meldet sich per Bearer-Token an; das prüft der Route Handler.
|
||||
const hatToken = anfrage.headers.get("authorization")?.startsWith("Bearer ") ?? false;
|
||||
|
||||
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE) && !hatToken) {
|
||||
// API bekommt 401 statt Umleitung: fetch folgte ihr sonst und hielte die Anmeldeseite für Daten.
|
||||
if (pfad.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ fehler: { code: "nicht_angemeldet", nachricht: "Nicht angemeldet." } },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
|
||||
const ziel = pfad + anfrage.nextUrl.search;
|
||||
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
|
||||
return NextResponse.redirect(anmelden);
|
||||
}
|
||||
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
const kopfzeilen = new Headers(anfrage.headers);
|
||||
kopfzeilen.set("x-nonce", nonce);
|
||||
kopfzeilen.set("x-pfad", pfad);
|
||||
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
|
||||
antwort.headers.set(
|
||||
"Content-Security-Policy",
|
||||
sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production"),
|
||||
);
|
||||
return antwort;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|ico|woff2?)$).*)"],
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "@/lib/validierung";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
|
||||
/**
|
||||
* Einheitliche Fehlerantwort der REST-API – Web-GUI und CLI lesen dasselbe Format:
|
||||
* `{ fehler: { code, nachricht, hinweis?, felder? } }`.
|
||||
*/
|
||||
export type FehlerAntwort = {
|
||||
fehler: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
|
||||
};
|
||||
|
||||
export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
|
||||
if (fehler instanceof AnwendungsFehler) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: {
|
||||
code: fehler.code,
|
||||
nachricht: fehler.message,
|
||||
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
|
||||
},
|
||||
},
|
||||
{ status: fehler.status },
|
||||
);
|
||||
}
|
||||
if (fehler instanceof ZodError) {
|
||||
const felder: Record<string, string> = {};
|
||||
for (const problem of fehler.issues) {
|
||||
const pfad = problem.path.join(".") || "_";
|
||||
felder[pfad] ??= problem.message;
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
fehler: {
|
||||
code: "ungueltig",
|
||||
nachricht: "Die Eingaben sind nicht vollständig oder ungültig.",
|
||||
felder,
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
logger.error({ err: fehler }, "Unerwarteter Fehler in der API");
|
||||
return NextResponse.json({ fehler: { code: "intern", nachricht: "Interner Fehler." } }, { status: 500 });
|
||||
}
|
||||
|
||||
/** Umhüllt einen Route Handler: wandelt geworfene Fehler in das einheitliche Format. */
|
||||
export function api<A extends unknown[]>(
|
||||
handler: (...argumente: A) => Promise<Response>,
|
||||
): (...argumente: A) => Promise<Response> {
|
||||
return async (...argumente: A) => {
|
||||
try {
|
||||
return await handler(...argumente);
|
||||
} catch (fehler) {
|
||||
return fehlerAntwort(fehler);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest einen JSON-Rumpf; ein kaputter Rumpf ist ein 400, kein 500. */
|
||||
export async function jsonLesen(anfrage: Request): Promise<unknown> {
|
||||
try {
|
||||
return await anfrage.json();
|
||||
} catch {
|
||||
throw new AnwendungsFehler(400, "kein_json", "Der Anfragerumpf ist kein gültiges JSON.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Rolle } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Wer was darf – die einzige Stelle. Seiten, Route Handler und Navigation fragen `darf()`;
|
||||
* niemand vergleicht selbst mit einer Rolle.
|
||||
*
|
||||
* Admin: Server, Katalog, Benutzer, Einstellungen. Operator: ansehen und ausrollen.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
lesen: ["ADMIN", "OPERATOR"],
|
||||
ausrollen: ["ADMIN", "OPERATOR"],
|
||||
vorlagenPflegen: ["ADMIN", "OPERATOR"],
|
||||
eigeneZugaenge: ["ADMIN", "OPERATOR"],
|
||||
serverVerwalten: ["ADMIN"],
|
||||
katalogVerwalten: ["ADMIN"],
|
||||
benutzerVerwalten: ["ADMIN"],
|
||||
einstellungenVerwalten: ["ADMIN"],
|
||||
protokollLesen: ["ADMIN"],
|
||||
} as const satisfies Record<string, readonly Rolle[]>;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
|
||||
export function darf(rolle: Rolle, recht: Recht): boolean {
|
||||
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import type { Quelle } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
|
||||
import { darf, type Recht } from "@/server/auth/rechte";
|
||||
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
|
||||
|
||||
/**
|
||||
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
|
||||
* Token. Jede Seite und jeder Route Handler prüft selbst – das Layout ist nur das zweite Netz,
|
||||
* weil Next es bei Navigation innerhalb desselben Layouts nicht neu rendert.
|
||||
*/
|
||||
export type Aufrufer = { benutzer: Benutzer; quelle: Quelle };
|
||||
|
||||
async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | null> {
|
||||
const sitzung = await sitzungLesen(jwt);
|
||||
if (!sitzung) return null;
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
|
||||
return benutzer?.aktiv ? benutzer : null;
|
||||
}
|
||||
|
||||
/** Für Server Components. */
|
||||
export async function aktuellerBenutzer(): Promise<Benutzer | null> {
|
||||
const speicher = await cookies();
|
||||
return await benutzerAusSitzung(speicher.get(SITZUNG_COOKIE)?.value);
|
||||
}
|
||||
|
||||
/** Für Seiten: leitet zur Anmeldung um oder zeigt „kein Recht“. */
|
||||
export async function seiteSchuetzen(recht: Recht, pfad: string): Promise<Benutzer> {
|
||||
const benutzer = await aktuellerBenutzer();
|
||||
if (!benutzer) redirect(`/anmelden?ziel=${encodeURIComponent(pfad)}`);
|
||||
if (!darf(benutzer.rolle, recht)) redirect("/kein-recht");
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
function cookieAusKopf(kopf: string | null, name: string): string | undefined {
|
||||
if (!kopf) return undefined;
|
||||
for (const teil of kopf.split(";")) {
|
||||
const [schluessel, ...wert] = teil.trim().split("=");
|
||||
if (schluessel === name) return decodeURIComponent(wert.join("="));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
|
||||
* landen hier, damit kein Handler zwei Wege kennen muss.
|
||||
*/
|
||||
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
|
||||
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
|
||||
return benutzer ? { benutzer, quelle: "WEB" } : null;
|
||||
}
|
||||
|
||||
export async function rechtErforderlich(anfrage: Request, recht: Recht): Promise<Aufrufer> {
|
||||
const aufrufer = await anfrageAufrufer(anfrage);
|
||||
if (!aufrufer) throw nichtAngemeldet();
|
||||
if (!darf(aufrufer.benutzer.rolle, recht)) throw keinRecht();
|
||||
return aufrufer;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Fehler, die bis zum Aufrufer durchgereicht werden dürfen. Alles andere wird als
|
||||
* „Interner Fehler“ gemeldet und nur ins Log geschrieben.
|
||||
*
|
||||
* `hinweis` sagt, was zu tun ist – Web und CLI zeigen ihn unter der Meldung an.
|
||||
*/
|
||||
export class AnwendungsFehler extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
nachricht: string,
|
||||
readonly hinweis?: string,
|
||||
) {
|
||||
super(nachricht);
|
||||
this.name = "AnwendungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export const nichtAngemeldet = () => new AnwendungsFehler(401, "nicht_angemeldet", "Nicht angemeldet.");
|
||||
|
||||
export const keinRecht = () =>
|
||||
new AnwendungsFehler(
|
||||
403,
|
||||
"kein_recht",
|
||||
"Dafür fehlt die Berechtigung.",
|
||||
"Ein Administrator kann die Rolle ändern.",
|
||||
);
|
||||
|
||||
export const nichtGefunden = (was: string) =>
|
||||
new AnwendungsFehler(404, "nicht_gefunden", `${was} gibt es nicht.`);
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Benutzer } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { attrappenPruefung, passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
|
||||
import { AnwendungsFehler } from "@/server/fehler";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
|
||||
/**
|
||||
* Sperre gegen Durchprobieren: höchstens 10 Fehlversuche je Benutzername und Adresse in 15 min.
|
||||
* Bewusst im Speicher: ProxDeploy läuft als eine Instanz, und ein Neustart, der die Zähler
|
||||
* leert, ist für einen Angreifer kein Gewinn – er müsste den Dienst dafür neu starten können.
|
||||
*/
|
||||
const FENSTER_MS = 15 * 60 * 1000;
|
||||
const HOECHSTENS = 10;
|
||||
const fehlversuche = new Map<string, number[]>();
|
||||
|
||||
function versucheZaehlen(schluessel: string, jetzt: number): number {
|
||||
const liste = (fehlversuche.get(schluessel) ?? []).filter((t) => jetzt - t < FENSTER_MS);
|
||||
fehlversuche.set(schluessel, liste);
|
||||
return liste.length;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function sperrenZuruecksetzen(): void {
|
||||
fehlversuche.clear();
|
||||
}
|
||||
|
||||
export async function mitPasswortAnmelden(
|
||||
benutzername: string,
|
||||
passwort: string,
|
||||
ip: string | null,
|
||||
): Promise<Benutzer> {
|
||||
const name = benutzername.trim().toLowerCase();
|
||||
const schluessel = `${name}|${ip ?? "-"}`;
|
||||
const jetzt = Date.now();
|
||||
if (versucheZaehlen(schluessel, jetzt) >= HOECHSTENS) {
|
||||
throw new AnwendungsFehler(
|
||||
429,
|
||||
"zu_viele_versuche",
|
||||
"Zu viele Fehlversuche.",
|
||||
"In 15 Minuten erneut versuchen.",
|
||||
);
|
||||
}
|
||||
|
||||
const benutzer = await prisma.benutzer.findUnique({ where: { benutzername: name } });
|
||||
const passt =
|
||||
benutzer?.passwortHash && benutzer.aktiv
|
||||
? await passwortPruefen(benutzer.passwortHash, passwort)
|
||||
: await attrappenPruefung(passwort);
|
||||
|
||||
if (!benutzer || !passt) {
|
||||
fehlversuche.get(schluessel)?.push(jetzt);
|
||||
await protokollieren({
|
||||
benutzerId: benutzer?.id ?? null,
|
||||
quelle: "WEB",
|
||||
aktion: "Anmeldung fehlgeschlagen",
|
||||
ziel: `benutzer:${name}`,
|
||||
ip,
|
||||
});
|
||||
// Eine Meldung für alle Fälle – sie verrät nicht, ob es den Benutzer gibt.
|
||||
throw new AnwendungsFehler(401, "anmeldung_fehlgeschlagen", "Benutzername oder Passwort ist falsch.");
|
||||
}
|
||||
|
||||
fehlversuche.delete(schluessel);
|
||||
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
|
||||
return benutzer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt beim ersten Start den Administrator aus ADMIN_BENUTZER/ADMIN_PASSWORT an – nur solange die
|
||||
* Tabelle leer ist. Danach sind die Variablen wirkungslos, damit ein vergessener Eintrag in der
|
||||
* .env kein zweites Hintertürchen ist.
|
||||
*/
|
||||
export async function ersterAdminFallsLeer(): Promise<void> {
|
||||
if ((await prisma.benutzer.count()) > 0) return;
|
||||
const benutzername = process.env["ADMIN_BENUTZER"]?.trim().toLowerCase() || "admin";
|
||||
const passwort = process.env["ADMIN_PASSWORT"] ?? "";
|
||||
if (passwort.length < PASSWORT_MINDESTLAENGE) {
|
||||
logger.warn(
|
||||
`Es gibt noch keinen Benutzer. ADMIN_PASSWORT setzen (mindestens ${PASSWORT_MINDESTLAENGE} Zeichen) und neu starten.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let benutzer: Benutzer;
|
||||
try {
|
||||
benutzer = await prisma.benutzer.create({
|
||||
data: {
|
||||
benutzername,
|
||||
anzeigename: "Administrator",
|
||||
rolle: "ADMIN",
|
||||
passwortHash: await passwortHashen(passwort),
|
||||
},
|
||||
});
|
||||
} catch (fehler) {
|
||||
// Ein zweiter Prozess war schneller (app und worker starten gleichzeitig) – das ist in Ordnung.
|
||||
if ((fehler as { code?: string }).code === "P2002") return;
|
||||
throw fehler;
|
||||
}
|
||||
await protokollieren({
|
||||
benutzerId: benutzer.id,
|
||||
quelle: "SYSTEM",
|
||||
aktion: "Erster Administrator angelegt",
|
||||
});
|
||||
logger.info({ benutzername }, "Erster Administrator angelegt");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Quelle } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export type ProtokollEintrag = {
|
||||
benutzerId: string | null;
|
||||
quelle: Quelle;
|
||||
/** Lesbar und deutsch, z. B. „Server angelegt“. */
|
||||
aktion: string;
|
||||
/** Maschinenlesbar, z. B. „server:rz1“. */
|
||||
ziel?: string;
|
||||
details?: Prisma.InputJsonValue;
|
||||
ip?: string | null;
|
||||
};
|
||||
|
||||
type Client = Pick<typeof prisma, "protokoll">;
|
||||
|
||||
/** Schreibt einen Audit-Eintrag; in einer Transaktion mit `tx` als Client aufrufen. */
|
||||
export async function protokollieren(eintrag: ProtokollEintrag, client: Client = prisma): Promise<void> {
|
||||
await client.protokoll.create({
|
||||
data: {
|
||||
benutzerId: eintrag.benutzerId,
|
||||
quelle: eintrag.quelle,
|
||||
aktion: eintrag.aktion,
|
||||
ziel: eintrag.ziel ?? null,
|
||||
details: eintrag.details,
|
||||
ip: eintrag.ip ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { POST as anmeldenPost } from "@/app/api/v1/auth/anmelden/route";
|
||||
import { GET as ichGet } from "@/app/api/v1/ich/route";
|
||||
import { passwortHashen } from "@/lib/passwort";
|
||||
import { ersterAdminFallsLeer, sperrenZuruecksetzen } from "@/server/services/anmeldung";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
function anmeldung(benutzername: string, passwort: string): Request {
|
||||
return new Request("http://localhost/api/v1/auth/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ benutzername, passwort }),
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Anmeldung", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
sperrenZuruecksetzen();
|
||||
await testPrisma.benutzer.create({
|
||||
data: {
|
||||
benutzername: "florian",
|
||||
anzeigename: "Florian",
|
||||
rolle: "OPERATOR",
|
||||
passwortHash: await passwortHashen("richtiges-passwort"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("setzt bei richtigem Passwort ein HttpOnly-Cookie und /ich kennt den Benutzer", async () => {
|
||||
const antwort = await anmeldenPost(anmeldung("Florian", "richtiges-passwort"));
|
||||
expect(antwort.status).toBe(200);
|
||||
const cookie = antwort.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toMatch(/pd_sitzung=/);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
// Über http (ohne Proxy) darf das Cookie nicht Secure sein, sonst schickt der Browser es nie zurück.
|
||||
expect(cookie).not.toMatch(/Secure/i);
|
||||
|
||||
const ich = await ichGet(
|
||||
new Request("http://localhost/api/v1/ich", { headers: { cookie: cookie.split(";")[0]! } }),
|
||||
);
|
||||
expect(ich.status).toBe(200);
|
||||
expect(await ich.json()).toMatchObject({ benutzername: "florian", rolle: "OPERATOR" });
|
||||
});
|
||||
|
||||
it("meldet falsches Passwort und unbekannten Benutzer gleich", async () => {
|
||||
const falsch = await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
const unbekannt = await anmeldenPost(anmeldung("niemand", "falsch"));
|
||||
expect(falsch.status).toBe(401);
|
||||
expect(await falsch.json()).toEqual(await unbekannt.json());
|
||||
});
|
||||
|
||||
it("weist deaktivierte Benutzer ab", async () => {
|
||||
await testPrisma.benutzer.update({ where: { benutzername: "florian" }, data: { aktiv: false } });
|
||||
expect((await anmeldenPost(anmeldung("florian", "richtiges-passwort"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("sperrt nach zehn Fehlversuchen, auch für das richtige Passwort", async () => {
|
||||
for (let i = 0; i < 10; i += 1) await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
const antwort = await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
||||
expect(antwort.status).toBe(429);
|
||||
expect((await antwort.json()).fehler.hinweis).toMatch(/15 Minuten/);
|
||||
});
|
||||
|
||||
it("protokolliert Erfolg und Fehlschlag", async () => {
|
||||
await anmeldenPost(anmeldung("florian", "falsch"));
|
||||
await anmeldenPost(anmeldung("florian", "richtiges-passwort"));
|
||||
const aktionen = (await testPrisma.protokoll.findMany({ orderBy: { id: "asc" } })).map((e) => e.aktion);
|
||||
expect(aktionen).toEqual(["Anmeldung fehlgeschlagen", "Angemeldet"]);
|
||||
});
|
||||
|
||||
it("antwortet auf kaputtes JSON mit 400 statt 500", async () => {
|
||||
const antwort = await anmeldenPost(
|
||||
new Request("http://localhost/api/v1/auth/anmelden", { method: "POST", body: "{kaputt" }),
|
||||
);
|
||||
expect(antwort.status).toBe(400);
|
||||
});
|
||||
|
||||
it("/ich ohne Sitzung ist 401", async () => {
|
||||
expect((await ichGet(new Request("http://localhost/api/v1/ich"))).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Erster Administrator", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
it("wird nur angelegt, solange es keinen Benutzer gibt", async () => {
|
||||
process.env["ADMIN_BENUTZER"] = "Admin";
|
||||
process.env["ADMIN_PASSWORT"] = "ein-langes-startpasswort";
|
||||
await ersterAdminFallsLeer();
|
||||
await ersterAdminFallsLeer();
|
||||
const alle = await testPrisma.benutzer.findMany();
|
||||
expect(alle).toHaveLength(1);
|
||||
expect(alle[0]).toMatchObject({ benutzername: "admin", rolle: "ADMIN" });
|
||||
});
|
||||
|
||||
it("legt bei zu kurzem Passwort niemanden an", async () => {
|
||||
process.env["ADMIN_PASSWORT"] = "kurz";
|
||||
await ersterAdminFallsLeer();
|
||||
expect(await testPrisma.benutzer.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Regeln, die in der Datenbank selbst stehen – sie gelten auch, wenn jemand an der
|
||||
* Service-Schicht vorbei schreibt. Jede ist hier namentlich aufgeführt.
|
||||
*/
|
||||
describe.runIf(datenbankVerfuegbar)("Datenbankregeln", () => {
|
||||
beforeEach(datenbankLeeren);
|
||||
|
||||
it("protokoll_nur_anhaengen: Einträge lassen sich weder ändern noch löschen", async () => {
|
||||
const eintrag = await testPrisma.protokoll.create({ data: { quelle: "SYSTEM", aktion: "Test" } });
|
||||
await expect(
|
||||
testPrisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "Gefälscht" } }),
|
||||
).rejects.toThrow(/nur anhängbar/);
|
||||
await expect(testPrisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/nur anhängbar/);
|
||||
});
|
||||
|
||||
it("protokoll_nur_anhaengen: das Löschen eines Benutzers lässt seine Einträge stehen", async () => {
|
||||
const benutzer = await testPrisma.benutzer.create({ data: { benutzername: "weg", anzeigename: "Weg" } });
|
||||
await testPrisma.protokoll.create({
|
||||
data: { quelle: "WEB", aktion: "Angemeldet", benutzerId: benutzer.id },
|
||||
});
|
||||
await testPrisma.benutzer.delete({ where: { id: benutzer.id } });
|
||||
const eintraege = await testPrisma.protokoll.findMany();
|
||||
expect(eintraege).toHaveLength(1);
|
||||
expect(eintraege[0]?.benutzerId).toBeNull();
|
||||
});
|
||||
|
||||
it("benutzer_benutzername_format: nur Kleinbuchstaben, Ziffern, . _ -", async () => {
|
||||
await expect(
|
||||
testPrisma.benutzer.create({ data: { benutzername: "Florian Wilde", anzeigename: "x" } }),
|
||||
).rejects.toThrow(/benutzer_benutzername_format/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const BENUTZER = process.env["E2E_BENUTZER"] ?? "admin";
|
||||
const PASSWORT = process.env["E2E_PASSWORT"] ?? "proxdeploy-dev-admin";
|
||||
|
||||
async function anmelden(page: Page) {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/anmelden/);
|
||||
await page.getByLabel("Benutzername").fill(BENUTZER);
|
||||
await page.getByLabel("Passwort").fill(PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function keinQuerscrollen(page: Page) {
|
||||
const breiten = await page.evaluate(() => ({
|
||||
inhalt: document.documentElement.scrollWidth,
|
||||
fenster: window.innerWidth,
|
||||
}));
|
||||
expect(breiten.inhalt).toBeLessThanOrEqual(breiten.fenster);
|
||||
}
|
||||
|
||||
test("falsches Passwort zeigt eine Meldung mit Symbol und Text", async ({ page }) => {
|
||||
await page.goto("/anmelden");
|
||||
// Eigener Name: Fehlversuche zählen je Benutzer, der echte Admin soll nie gesperrt werden.
|
||||
await page.getByLabel("Benutzername").fill("e2e-unbekannt");
|
||||
await page.getByLabel("Passwort").fill("falsch-falsch");
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
// Wenn die Seite nicht hydriert, passiert hier nichts – das ist die eigentliche Prüfung.
|
||||
await expect(page.getByText("Benutzername oder Passwort ist falsch.")).toBeVisible();
|
||||
await expect(page.getByText("✗")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Anmelden, Übersicht, Abmelden", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await keinQuerscrollen(page);
|
||||
await page.screenshot({ path: `test-results/uebersicht-${test.info().project.name}.png`, fullPage: true });
|
||||
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByRole("button", { name: "Navigation öffnen" }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Abmelden" }).click();
|
||||
await expect(page).toHaveURL(/\/anmelden/);
|
||||
});
|
||||
|
||||
test("Theme-Wechsel bleibt nach dem Neuladen erhalten", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dunkel");
|
||||
if (test.info().project.name === "handy") {
|
||||
await page.getByRole("button", { name: "Navigation öffnen" }).click();
|
||||
}
|
||||
await page.getByRole("button", { name: "Helles Theme einschalten" }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "hell");
|
||||
await page.screenshot({
|
||||
path: `test-results/uebersicht-hell-${test.info().project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("Anmeldeseite ohne Querscrollen", async ({ page }) => {
|
||||
await page.goto("/anmelden");
|
||||
await keinQuerscrollen(page);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { entschluesseln, GeheimnisFehler, verschluesseln } from "@/lib/geheimnisse";
|
||||
|
||||
const ursprung = process.env["PROXDEPLOY_SCHLUESSEL"];
|
||||
afterEach(() => {
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = ursprung;
|
||||
});
|
||||
|
||||
describe("Geheimnisse (AES-256-GCM)", () => {
|
||||
it("verschlüsselt und entschlüsselt verlustfrei, auch Umlaute und Zeilenumbrüche", () => {
|
||||
const klar = "-----BEGIN OPENSSH PRIVATE KEY-----\nÄÖÜ ß\n-----END-----\n";
|
||||
expect(entschluesseln(verschluesseln(klar))).toBe(klar);
|
||||
});
|
||||
|
||||
it("erzeugt bei gleichem Klartext jedes Mal einen anderen Geheimtext (neuer IV)", () => {
|
||||
expect(verschluesseln("geheim")).not.toBe(verschluesseln("geheim"));
|
||||
});
|
||||
|
||||
it("trägt das Versionspräfix v1", () => {
|
||||
expect(verschluesseln("x").startsWith("v1.")).toBe(true);
|
||||
});
|
||||
|
||||
it("wirft bei verändertem Geheimtext", () => {
|
||||
const teile = verschluesseln("geheim").split(".");
|
||||
const daten = Buffer.from(teile[3]!, "base64");
|
||||
daten[0] = daten[0]! ^ 0xff;
|
||||
teile[3] = daten.toString("base64");
|
||||
expect(() => entschluesseln(teile.join("."))).toThrow(GeheimnisFehler);
|
||||
});
|
||||
|
||||
it("wirft mit anderem Schlüssel", () => {
|
||||
const geheim = verschluesseln("geheim");
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 9).toString("base64");
|
||||
expect(() => entschluesseln(geheim)).toThrow(/nicht mehr derselbe/);
|
||||
});
|
||||
|
||||
it("verlangt genau 32 Byte Schlüssel und nennt den Befehl zum Erzeugen", () => {
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(16).toString("base64");
|
||||
expect(() => verschluesseln("x")).toThrow(/openssl rand -base64 32/);
|
||||
});
|
||||
|
||||
it("lehnt unbekannte Formate und Versionen ab", () => {
|
||||
expect(() => entschluesseln("klartext")).toThrow(/unbekanntes Format/);
|
||||
expect(() => entschluesseln("v9.a.b.c")).toThrow(/unbekannte Version/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import "dotenv/config";
|
||||
|
||||
/**
|
||||
* Spielt die Migrationen in die Testdatenbank ein. Ohne TEST_DATABASE_URL überspringen sich die
|
||||
* Datenbanktests – und sagen das laut, damit ein grüner Lauf nicht mehr verspricht, als er prüft.
|
||||
*/
|
||||
export default async function aufbau(): Promise<void> {
|
||||
const url = process.env["TEST_DATABASE_URL"];
|
||||
if (!url) {
|
||||
console.warn("\n TEST_DATABASE_URL fehlt – Datenbanktests werden ÜBERSPRUNGEN. Start: pnpm db:up\n");
|
||||
return;
|
||||
}
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
execFileSync("pnpm", ["exec", "prisma", "migrate", "deploy"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const url = process.env["TEST_DATABASE_URL"];
|
||||
|
||||
// In CI darf nichts still übersprungen werden – dort lieber hart abbrechen.
|
||||
if (!url && process.env["CI"]) {
|
||||
throw new Error("TEST_DATABASE_URL fehlt. In CI dürfen die Datenbanktests nicht übersprungen werden.");
|
||||
}
|
||||
|
||||
export const datenbankVerfuegbar = Boolean(url);
|
||||
|
||||
export const testPrisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: url ?? "postgresql://localhost:5437/proxdeploy_test" }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Leert alle Tabellen. Das Protokoll ist per Trigger nur anhängbar – TRUNCATE löst
|
||||
* Zeilen-Trigger nicht aus und ist deshalb der einzige Weg, es zu leeren.
|
||||
*/
|
||||
export async function datenbankLeeren(): Promise<void> {
|
||||
const tabellen = await testPrisma.$queryRaw<{ tablename: string }[]>`
|
||||
SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename NOT LIKE '_prisma%'
|
||||
`;
|
||||
if (tabellen.length === 0) return;
|
||||
const liste = tabellen.map((t) => `"public"."${t.tablename}"`).join(", ");
|
||||
await testPrisma.$executeRawUnsafe(`TRUNCATE ${liste} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Kontrast mindestens 4,5:1 (WCAG AA) für Text auf Grund und Panel – in beiden Themes.
|
||||
* Liest die Tokens direkt aus globals.css, damit eine Farbänderung hier auffällt.
|
||||
*/
|
||||
const css = readFileSync("src/app/globals.css", "utf8");
|
||||
|
||||
function block(selektor: string): Record<string, string> {
|
||||
const anfang = css.indexOf(selektor);
|
||||
const inhalt = css.slice(css.indexOf("{", anfang) + 1, css.indexOf("}", anfang));
|
||||
return Object.fromEntries(
|
||||
[...inhalt.matchAll(/--farbe-([a-z-]+):\s*(#[0-9a-f]{6})/gi)].map((t) => [t[1]!, t[2]!]),
|
||||
);
|
||||
}
|
||||
|
||||
function leuchtdichte(hex: string): number {
|
||||
const kanal = (i: number) => {
|
||||
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * kanal(1) + 0.7152 * kanal(3) + 0.0722 * kanal(5);
|
||||
}
|
||||
|
||||
export function kontrast(a: string, b: string): number {
|
||||
const [hell, dunkel] = [leuchtdichte(a), leuchtdichte(b)].sort((x, y) => y - x) as [number, number];
|
||||
return (hell + 0.05) / (dunkel + 0.05);
|
||||
}
|
||||
|
||||
const THEMES = { dunkel: block('[data-theme="dunkel"]'), hell: block('[data-theme="hell"]') };
|
||||
const TEXTFARBEN = ["text", "muted", "akzent", "laeuft", "fehler"];
|
||||
|
||||
describe("Kontrast der Theme-Farben", () => {
|
||||
for (const [name, farben] of Object.entries(THEMES)) {
|
||||
for (const flaeche of ["grund", "panel", "panel-hoch"]) {
|
||||
it.each(TEXTFARBEN)(`${name}: %s auf ${flaeche} ≥ 4,5:1`, (textfarbe) => {
|
||||
expect(kontrast(farben[textfarbe]!, farben[flaeche]!)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
it(`${name}: Text auf Akzentfläche ≥ 4,5:1`, () => {
|
||||
expect(kontrast(farben["akzent-text"]!, farben["akzent"]!)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
|
||||
// Tests laufen immer gegen die Testdatenbank, nie gegen die Entwicklungsdatenbank.
|
||||
if (process.env["TEST_DATABASE_URL"]) {
|
||||
process.env["DATABASE_URL"] = process.env["TEST_DATABASE_URL"];
|
||||
}
|
||||
|
||||
// Feste Testgeheimnisse, damit kein Test eine .env verlangt.
|
||||
process.env["PROXDEPLOY_SCHLUESSEL"] = Buffer.alloc(32, 7).toString("base64");
|
||||
process.env["SITZUNG_GEHEIMNIS"] = "test-sitzungsgeheimnis-mit-mindestens-32-zeichen";
|
||||
process.env["LOG_LEVEL"] = "silent";
|
||||
process.env["TRUST_PROXY"] = "false";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sicherheitsrichtlinie } from "@/proxy";
|
||||
|
||||
describe("Content-Security-Policy", () => {
|
||||
it("ist im gebauten Stand streng", () => {
|
||||
const csp = sicherheitsrichtlinie("abc", false);
|
||||
expect(csp).toContain("script-src 'self' 'nonce-abc' 'strict-dynamic'");
|
||||
expect(csp).not.toContain("unsafe-eval");
|
||||
expect(csp).not.toContain("ws:");
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it("lockert nur im Entwicklungsmodus", () => {
|
||||
expect(sicherheitsrichtlinie("abc", true)).toContain("'unsafe-eval'");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SignJWT } from "jose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||||
|
||||
describe("Sitzungscookie", () => {
|
||||
it("liest ein selbst ausgestelltes Token zurück", async () => {
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
|
||||
expect(await sitzungLesen(jwt)).toMatchObject({ benutzerId: "b1", herkunft: "passwort" });
|
||||
});
|
||||
|
||||
it("verwirft fehlende, verfälschte und fremd signierte Tokens", async () => {
|
||||
expect(await sitzungLesen(undefined)).toBeUndefined();
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "passwort" });
|
||||
expect(await sitzungLesen(jwt.slice(0, -2) + "xx")).toBeUndefined();
|
||||
const fremd = await new SignJWT({ herkunft: "passwort" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject("b1")
|
||||
.setIssuer("proxdeploy")
|
||||
.setAudience("proxdeploy-web")
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("1h")
|
||||
.sign(new TextEncoder().encode("ein-ganz-anderes-geheimnis-mit-genug-zeichen"));
|
||||
expect(await sitzungLesen(fremd)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("enthält keine Rolle – die kommt je Anfrage aus der Datenbank", async () => {
|
||||
const jwt = await sitzungErzeugen({ benutzerId: "b1", herkunft: "oidc" });
|
||||
const inhalt = JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||
expect(inhalt).not.toHaveProperty("rolle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
|
||||
|
||||
/**
|
||||
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
|
||||
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
|
||||
* jemanden zwingen, sie aufzuschreiben.
|
||||
*/
|
||||
const AUSNAHMEN: Record<string, string> = {
|
||||
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
|
||||
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
|
||||
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
|
||||
"src/app/anmelden/page.tsx": "Anmeldeseite",
|
||||
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
|
||||
};
|
||||
|
||||
function dateien(verzeichnis: string): string[] {
|
||||
return readdirSync(verzeichnis).flatMap((name) => {
|
||||
const pfad = join(verzeichnis, name);
|
||||
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
|
||||
});
|
||||
}
|
||||
|
||||
const wurzel = process.cwd();
|
||||
const kandidaten = dateien(join(wurzel, "src/app"))
|
||||
.map((pfad) => relative(wurzel, pfad))
|
||||
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
|
||||
|
||||
describe("Zugangsprüfung", () => {
|
||||
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
|
||||
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
|
||||
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
|
||||
});
|
||||
|
||||
it("jede Ausnahme gibt es noch", () => {
|
||||
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
|
||||
});
|
||||
|
||||
it("öffentliche Pfade sind genau die bekannten", () => {
|
||||
expect(OEFFENTLICH).toEqual([
|
||||
"/anmelden",
|
||||
"/api/v1/auth",
|
||||
"/api/v1/gesundheit",
|
||||
"/brand",
|
||||
"/manifest.webmanifest",
|
||||
]);
|
||||
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
|
||||
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
|
||||
expect(istOeffentlich("/server")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.mts",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "cli", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "src/**/*.test.ts"],
|
||||
environment: "node",
|
||||
globalSetup: ["tests/global-setup.ts"],
|
||||
setupFiles: ["tests/setup-env.ts"],
|
||||
// Datenbanktests teilen sich eine Datenbank und dürfen sich nicht überholen.
|
||||
fileParallelism: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user