Merge: Meilenstein 1 – Fundament (0.1.0)
Anmeldung über OIDC, Passkeys und Passwort, Rollen mit serverseitiger Prüfung in Seite und Server Action, Audit-Protokoll, Instanzsteuerung, Bereiche, Betrieb über Docker Compose sowie die vollständige Test- und Dokumentationskette. Geprüft vor dem Merge: npm run pruefen, 19 Integrationsprüfungen, Migrationstest und 9 E2E-Tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,27 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
logs
|
||||||
|
sicherungen
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Nur für die Entwicklung. Halten den Kontext klein und sorgen dafür, dass ein
|
||||||
|
# defektes Werkzeugskript nicht den Bau des Abbilds blockiert – geprüft werden
|
||||||
|
# sie ohnehin von "npm run pruefen".
|
||||||
|
e2e
|
||||||
|
screenshots
|
||||||
|
test
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
playwright.config.ts
|
||||||
|
playwright.screenshots.config.ts
|
||||||
|
vitest.config.ts
|
||||||
|
docs
|
||||||
|
devlog
|
||||||
|
*.sh
|
||||||
|
scripts/pruefen.mjs
|
||||||
|
scripts/integrationstest.ts
|
||||||
|
scripts/migrationstest.ts
|
||||||
|
scripts/screenshots.ts
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Planer – Beispielkonfiguration
|
||||||
|
# Kopieren nach .env und ausfuellen. .env gehoert NIE ins Git.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Welche Instanz laeuft hier? Steuert Provider, Integrationen und Routen.
|
||||||
|
# Erlaubt: firma | daheim
|
||||||
|
INSTANZ=daheim
|
||||||
|
|
||||||
|
# Oeffentliche Adresse der Anwendung, ohne abschliessenden Schraegstrich.
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Datenbank -------------------------------------------------------------
|
||||||
|
# Muss zu den POSTGRES_*-Werten unten passen, wenn Docker Compose benutzt wird.
|
||||||
|
DATABASE_URL=postgresql://planer:bitte-aendern@localhost:5432/planer?schema=public
|
||||||
|
|
||||||
|
# Nur fuer docker compose: Zugangsdaten des Postgres-Containers.
|
||||||
|
POSTGRES_USER=planer
|
||||||
|
POSTGRES_PASSWORD=bitte-aendern
|
||||||
|
POSTGRES_DB=planer
|
||||||
|
|
||||||
|
# Nach aussen veroeffentlichte Ports. Aendern, wenn 5432 oder 3000 auf dem
|
||||||
|
# Rechner schon belegt sind; DATABASE_URL muss dann denselben Port nennen.
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
APP_PORT=3000
|
||||||
|
|
||||||
|
# --- Anmeldung -------------------------------------------------------------
|
||||||
|
# Signiert Sitzungen und Tokens. Erzeugen mit:
|
||||||
|
# openssl rand -base64 32
|
||||||
|
AUTH_SECRET=
|
||||||
|
|
||||||
|
# Hinter einem Reverse Proxy auf true setzen, damit Auth.js dem Host vertraut.
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
# Anmeldung per E-Mail und Passwort erlauben. In der Firma ueblicherweise
|
||||||
|
# false, damit nur ueber OIDC angemeldet wird.
|
||||||
|
ANMELDUNG_PASSWORT=true
|
||||||
|
|
||||||
|
# Erstes Verwaltungskonto, wird vom Seed angelegt. Ohne ADMIN_PASSWORT
|
||||||
|
# entsteht ein Konto ohne Passwort, das sich nur per OIDC oder Passkey
|
||||||
|
# anmelden kann. Nach der Einrichtung dürfen beide Werte wieder raus.
|
||||||
|
ADMIN_EMAIL=
|
||||||
|
ADMIN_PASSWORT=
|
||||||
|
|
||||||
|
# --- OIDC ------------------------------------------------------------------
|
||||||
|
# firma = Entra ID, daheim = authentik. Leer lassen schaltet OIDC ab.
|
||||||
|
# Der Rueckleit-URI beim Anbieter lautet:
|
||||||
|
# <APP_URL>/api/auth/callback/oidc
|
||||||
|
OIDC_NAME=
|
||||||
|
OIDC_ISSUER=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# --- Passkeys (WebAuthn) ---------------------------------------------------
|
||||||
|
# RP_ID ist die nackte Domain ohne Schema und Port, z. B. planer.example.de.
|
||||||
|
# Lokal ist "localhost" richtig.
|
||||||
|
WEBAUTHN_RP_ID=localhost
|
||||||
|
WEBAUTHN_RP_NAME=Planer
|
||||||
|
# Muss exakt der Herkunft im Browser entsprechen, inklusive Port.
|
||||||
|
WEBAUTHN_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# Nur fuer die Screenshot-Instanz. Schaltet den Demo-Kalenderprovider frei
|
||||||
|
# und darf in echten Umgebungen niemals true sein.
|
||||||
|
DEMO_MODUS=false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Im Git immer LF, damit die Shellskripte auch unter Linux laufen.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.ico binary
|
||||||
|
*.woff2 binary
|
||||||
|
package-lock.json -diff
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Abhängigkeiten
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Erzeugter Prisma-Client
|
||||||
|
src/generated/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
.playwright/
|
||||||
|
|
||||||
|
# Geheimnisse und lokale Umgebung
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Betrieb
|
||||||
|
logs/
|
||||||
|
sicherungen/
|
||||||
|
*.dump
|
||||||
|
|
||||||
|
# Zeiterfassung: laufender Eintrag ist lokal
|
||||||
|
devlog/.zeit-offen
|
||||||
|
|
||||||
|
# Werkzeuge
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
screenshot-lauf.log
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
docs/bilder
|
||||||
|
prisma/migrations
|
||||||
|
logs
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
coverage
|
||||||
|
devlog/zeiten.csv
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 90,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an diesem Projekt stehen hier.
|
||||||
|
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||||
|
die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||||
|
|
||||||
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.1.0] – 2026-09-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Projektgerüst mit Next 16 (App Router, `output: "standalone"`), React 19,
|
||||||
|
TypeScript strict, Tailwind 4 und shadcn-Primitiven.
|
||||||
|
- `npm run pruefen` bündelt Prettier, ESLint, `tsc --noEmit`, Vitest und
|
||||||
|
ShellCheck. ShellCheck läuft über das Docker-Abbild, wenn es lokal fehlt.
|
||||||
|
- Datenmodell für Benutzer mit Rolle, OIDC-Konten, Passkeys, Bereiche und
|
||||||
|
Audit-Einträge; PostgreSQL 16 über Prisma 7 mit pg-Treiberadapter.
|
||||||
|
- Anmeldung über einen generischen OIDC-Anbieter, über Passkeys
|
||||||
|
(SimpleWebAuthn) und wahlweise über E-Mail und Passwort.
|
||||||
|
- Rollen `BENUTZER` und `ADMIN`. Jede schreibende Aktion prüft die Rolle
|
||||||
|
serverseitig in der Seite **und** noch einmal in der Server Action.
|
||||||
|
- Audit-Protokoll für jede schreibende Aktion, mit Protokollseite für die
|
||||||
|
Verwaltung. Details werden vor dem Speichern gesäubert.
|
||||||
|
- Instanzsteuerung über `INSTANZ=firma|daheim`. Was eine Instanz nicht darf,
|
||||||
|
liefert dort 404 und lädt seine Konfiguration gar nicht erst.
|
||||||
|
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag.
|
||||||
|
- Zeitrechnung durchgehend in Europe/Berlin, mit Tagesgrenzen, Wochenbeginn
|
||||||
|
am Montag und deutschen Anzeigeformaten.
|
||||||
|
- `/api/health` mit Status, Version, Instanz und Erreichbarkeit der Datenbank.
|
||||||
|
- Hell und Dunkel, Navigation mit Rollenfilter, Oberfläche auf Deutsch.
|
||||||
|
- Dockerfile in drei Stufen und Compose-Stapel mit eigenem Migrationsdienst.
|
||||||
|
- `backup.sh` (pg_dump im Custom-Format, `.env` und Uploads),
|
||||||
|
`update.sh` (mit Rückrollpfad), `scripts/release.sh` und `scripts/zeit.sh`.
|
||||||
|
- Tests: Unit mit Vitest, Integrationstest und Migrationstest gegen eine
|
||||||
|
echte Datenbank, E2E-Rauchtest mit Playwright.
|
||||||
|
- `npm run screenshots` nimmt die Handbuchbilder in einer Wegwerf-Instanz mit
|
||||||
|
ausschließlich erfundenen Daten auf.
|
||||||
|
- README, Schnellstart, Handbuch, ROADMAP und DEVLOG.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich,
|
||||||
|
damit sich nicht abfragen lässt, wer ein Konto hat.
|
||||||
|
- Sitzungen als JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
|
||||||
|
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
|
||||||
|
- Die WebAuthn-Herausforderung wird nach einem Versuch verworfen, gleich ob
|
||||||
|
er geglückt ist.
|
||||||
|
- Der Demo-Seed verweigert den Dienst ohne `DEMO_MODUS=true`.
|
||||||
|
- `allowDangerousEmailAccountLinking` ist bewusst aktiv: je Instanz gibt es
|
||||||
|
genau einen vertrauenswürdigen OIDC-Anbieter, der die Adresse selbst prüft.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Planer – Abbild für den Betrieb.
|
||||||
|
#
|
||||||
|
# Die Stufe "bauen" dient zugleich als Abbild für den Migrationsdienst: die
|
||||||
|
# Prisma-CLI bringt eine ganze Reihe Module mit, die im schlanken
|
||||||
|
# Laufzeitabbild nichts zu suchen haben. Siehe docker-compose.yml.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FROM node:24-alpine AS abhaengigkeiten
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# Die Bauwerkzeuge werden gebraucht und bleiben in dieser Stufe.
|
||||||
|
RUN npm ci --ignore-scripts && npm rebuild prisma @prisma/engines esbuild unrs-resolver
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:24-alpine AS bauen
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Der Prisma-Client wird erzeugt, bevor Next baut.
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:24-alpine AS betrieb
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME=0.0.0.0 \
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
RUN apk add --no-cache tzdata curl \
|
||||||
|
&& addgroup --system --gid 1001 planer \
|
||||||
|
&& adduser --system --uid 1001 --ingroup planer planer
|
||||||
|
|
||||||
|
# Die standalone-Ausgabe bringt nur die wirklich benutzten Module mit.
|
||||||
|
COPY --from=bauen --chown=planer:planer /app/.next/standalone ./
|
||||||
|
COPY --from=bauen --chown=planer:planer /app/.next/static ./.next/static
|
||||||
|
COPY --from=bauen --chown=planer:planer /app/public ./public
|
||||||
|
|
||||||
|
USER planer
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Planer
|
||||||
|
|
||||||
|
Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.
|
||||||
|
|
||||||
|
Die Kernidee: Kalender aus Microsoft 365 (Firma, Gewerbe) und Google (privat)
|
||||||
|
lesen und Aufgaben per Ziehen und Ablegen als Zeitblöcke direkt in diese
|
||||||
|
Kalender schreiben — inklusive Rückweg, wenn ein Block im Kalender verschoben
|
||||||
|
wird.
|
||||||
|
|
||||||
|
> **Stand:** Meilenstein 1 von 8. Die Grundlage steht: Anmeldung, Rollen,
|
||||||
|
> Protokoll, Bereiche und der Betrieb. Aufgaben kommen mit M2, das Lesen der
|
||||||
|
> Kalender mit M3, die Wochenplanung mit M4. Der [Fahrplan](ROADMAP.md) sagt,
|
||||||
|
> was wann folgt.
|
||||||
|
|
||||||
|
- **[Schnellstart](docs/SCHNELLSTART.md)** – in zehn Minuten von `git clone`
|
||||||
|
zur ersten Anmeldung
|
||||||
|
- **[Handbuch](docs/handbuch/)** – Anleitung für den Alltag
|
||||||
|
- **[Fahrplan](ROADMAP.md)** – was geplant ist und was bewusst nicht
|
||||||
|
- **[Änderungen](CHANGELOG.md)** – was sich je Version getan hat
|
||||||
|
|
||||||
|
## Funktionsumfang
|
||||||
|
|
||||||
|
### Heute
|
||||||
|
|
||||||
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
|
serverseitig geprüft und hinterlässt einen Protokolleintrag
|
||||||
|
- Bereiche mit Farbe, Arbeitsfenster und Tageskapazität je Wochentag
|
||||||
|
- Hell und Dunkel, Oberfläche durchgehend auf Deutsch
|
||||||
|
- Betrieb über Docker Compose, mit Sicherung, Aktualisierung und Healthcheck
|
||||||
|
|
||||||
|
### Geplant
|
||||||
|
|
||||||
|
Aufgaben und Projekte, Kalender lesen und schreiben, Wochenplanung per Ziehen
|
||||||
|
und Ablegen, Benachrichtigungen, Integrationen, Schnellerfassung vom Handy und
|
||||||
|
ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
## Bildergalerie
|
||||||
|
|
||||||
|
| | Hell | Dunkel |
|
||||||
|
| ------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| Anmelden |  |  |
|
||||||
|
| Start |  |  |
|
||||||
|
| Bereiche |  |  |
|
||||||
|
| Einstellungen |  |  |
|
||||||
|
| Protokoll |  |  |
|
||||||
|
|
||||||
|
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
||||||
|
ausschließlich erfundenen Daten — niemals aus einer Instanz mit echten Daten.
|
||||||
|
|
||||||
|
## Die zwei Instanzen
|
||||||
|
|
||||||
|
Ein Abbild, zwei Deployments, gesteuert über `INSTANZ`. Die Umschaltung wird
|
||||||
|
**serverseitig erzwungen**: Was eine Instanz nicht darf, liefert dort 404 und
|
||||||
|
lädt seine Konfiguration gar nicht erst.
|
||||||
|
|
||||||
|
| | `firma` | `daheim` |
|
||||||
|
| -------------- | -------------------------------------- | ------------------------------ |
|
||||||
|
| Anmeldung | OIDC Entra ID | OIDC authentik |
|
||||||
|
| Kalenderkonten | Graph (Firmen-Tenant) | Graph (Gewerbe-Tenant), Google |
|
||||||
|
| Mail | Graph Firmenpostfach | Graph Gewerbepostfach, Gmail |
|
||||||
|
| Integrationen | Zammad, Wartungsplaner, Spiegel senden | Telegram, Spiegel empfangen |
|
||||||
|
|
||||||
|
Ein falsch geschriebener Wert bricht den Start ab, statt still auf einen
|
||||||
|
Standard zu fallen — in der Firma dürfen keine Provider anlaufen, die dort
|
||||||
|
nichts zu suchen haben.
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
Alle Werte stehen in `.env`. Vorlage: [`.env.example`](.env.example).
|
||||||
|
`.env` gehört niemals ins Git.
|
||||||
|
|
||||||
|
### Pflicht
|
||||||
|
|
||||||
|
| Variable | Beispiel | Bedeutung |
|
||||||
|
| ----------------- | ---------------------------------------------------- | ---------------------------------------------- |
|
||||||
|
| `INSTANZ` | `daheim` | `firma` oder `daheim` |
|
||||||
|
| `APP_URL` | `https://planer.example.de` | Öffentliche Adresse, ohne Schrägstrich am Ende |
|
||||||
|
| `DATABASE_URL` | `postgresql://planer:…@db:5432/planer?schema=public` | Verbindung zur Datenbank |
|
||||||
|
| `AUTH_SECRET` | _(32 Byte Base64)_ | Signiert Sitzungen. `openssl rand -base64 32` |
|
||||||
|
| `WEBAUTHN_RP_ID` | `planer.example.de` | Nackte Domain, ohne Schema und Port |
|
||||||
|
| `WEBAUTHN_ORIGIN` | `https://planer.example.de` | Exakt die Herkunft im Browser |
|
||||||
|
|
||||||
|
### Datenbank im Compose-Stapel
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ------------------- | ---------- | ---------------------------------------------- |
|
||||||
|
| `POSTGRES_USER` | `planer` | Benutzer der Datenbank |
|
||||||
|
| `POSTGRES_PASSWORD` | _(keiner)_ | Pflicht. Muss zu `DATABASE_URL` passen |
|
||||||
|
| `POSTGRES_DB` | `planer` | Name der Datenbank |
|
||||||
|
| `POSTGRES_PORT` | `5432` | Nach außen veröffentlichter Port |
|
||||||
|
| `APP_PORT` | `3000` | Nach außen veröffentlichter Port der Anwendung |
|
||||||
|
|
||||||
|
### Anmeldung
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| -------------------- | -------- | ------------------------------------------ |
|
||||||
|
| `AUTH_TRUST_HOST` | `false` | Hinter einem Reverse Proxy auf `true` |
|
||||||
|
| `ANMELDUNG_PASSWORT` | `false` | Anmeldung mit E-Mail und Passwort erlauben |
|
||||||
|
| `OIDC_NAME` | _(leer)_ | Beschriftung des Anmeldeknopfs |
|
||||||
|
| `OIDC_ISSUER` | _(leer)_ | Issuer-URL des Anbieters |
|
||||||
|
| `OIDC_CLIENT_ID` | _(leer)_ | Client-ID |
|
||||||
|
| `OIDC_CLIENT_SECRET` | _(leer)_ | Client-Secret |
|
||||||
|
| `WEBAUTHN_RP_NAME` | `Planer` | Name, den das Gerät beim Passkey anzeigt |
|
||||||
|
|
||||||
|
OIDC ist aktiv, sobald Issuer, Client-ID und Secret gesetzt sind. Der
|
||||||
|
Rückleit-URI beim Anbieter lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||||
|
|
||||||
|
### Einrichtung und Sonstiges
|
||||||
|
|
||||||
|
| Variable | Standard | Bedeutung |
|
||||||
|
| ---------------- | --------------- | ---------------------------------------------------------------------------------------- |
|
||||||
|
| `ADMIN_EMAIL` | _(leer)_ | Erstes Verwaltungskonto, wird vom Seed angelegt |
|
||||||
|
| `ADMIN_PASSWORT` | _(leer)_ | Mindestens 12 Zeichen. Ohne den Wert entsteht ein Konto, das nur OIDC oder Passkey kennt |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Anzeigen und Tagesgrenzen |
|
||||||
|
| `DEMO_MODUS` | `false` | Nur für die Screenshot-Instanz. Im Betrieb niemals `true` |
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
### Starten und stoppen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # starten
|
||||||
|
docker compose logs -f app # Protokoll mitlesen
|
||||||
|
docker compose down # stoppen, Daten bleiben
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Stapel besteht aus drei Diensten: `db`, ein Einmaldienst `migration`, der
|
||||||
|
die Migrationen einspielt und sich beendet, und `app`.
|
||||||
|
|
||||||
|
### Healthcheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://planer.example.de/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"instanz": "daheim",
|
||||||
|
"datenbank": "ok",
|
||||||
|
"zeit": "2026-03-17T08:20:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Auskunft ist bewusst ohne Anmeldung erreichbar und enthält deshalb keine
|
||||||
|
Zählstände, Namen oder Adressen. Bei `"status": "fehler"` antwortet der
|
||||||
|
Endpunkt mit HTTP 503.
|
||||||
|
|
||||||
|
### Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh # auf den neuesten Tag
|
||||||
|
./update.sh --nur-pruefen # nur zeigen, was passieren würde
|
||||||
|
./update.sh --version v0.2.0 # auf eine bestimmte Version
|
||||||
|
./update.sh --ja # ohne Rückfrage
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript prüft die Voraussetzungen, zeigt die Änderungen aus dem CHANGELOG,
|
||||||
|
legt eine Sicherung an, wechselt auf den Ziel-Tag, baut, startet und wartet
|
||||||
|
auf einen gesunden `/api/health` mit der erwarteten Version. Klappt das nicht,
|
||||||
|
rollt es auf die vorherige Version zurück und nennt den Pfad der Sicherung.
|
||||||
|
Jeder Lauf wird nach `logs/update-<Datum>.log` protokolliert.
|
||||||
|
|
||||||
|
Die **Datenbank wird beim Zurückrollen nicht automatisch zurückgespielt** —
|
||||||
|
das wäre bei einem Teilfehler gefährlicher als hilfreich. Das Skript nennt den
|
||||||
|
Befehl dafür.
|
||||||
|
|
||||||
|
### Sicherung und Wiederherstellung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./backup.sh # Datenbank, .env und Uploads
|
||||||
|
./backup.sh --tage 90 # längere Aufbewahrung
|
||||||
|
./backup.sh --nur-datenbank # nur der Dump
|
||||||
|
```
|
||||||
|
|
||||||
|
Je Lauf entstehen zwei Dateien in `sicherungen/`:
|
||||||
|
ein `pg_dump` im Custom-Format und ein tar.gz mit `.env` und `uploads`.
|
||||||
|
|
||||||
|
Wiederherstellen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Datenbank
|
||||||
|
docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists \
|
||||||
|
< sicherungen/planer-db-2026-03-17_02-00-00.dump
|
||||||
|
|
||||||
|
# Dateien
|
||||||
|
tar -xzf sicherungen/planer-dateien-2026-03-17_02-00-00.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Sicherung enthält `.env` und damit alle Geheimnisse. Sie gehört so
|
||||||
|
geschützt wie die Datenbank selbst.
|
||||||
|
|
||||||
|
Nächtlich per cron:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 2 * * * cd /opt/planer && ./backup.sh --tage 30 >> logs/backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
docker compose up -d db # nur die Datenbank
|
||||||
|
npm run db:migrieren # Migrationen einspielen
|
||||||
|
npm run db:seed # Bereiche und Verwaltungskonto
|
||||||
|
npm run dev # Entwicklungsserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prüfungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pruefen # Prettier, ESLint, tsc, Vitest, ShellCheck
|
||||||
|
npm run test:integration # gegen eine echte Datenbank
|
||||||
|
npm run test:migration # Migrationen gegen eine Wegwerf-Datenbank
|
||||||
|
npm run test:e2e # Playwright gegen die gebaute Anwendung
|
||||||
|
npm run screenshots # Handbuchbilder neu aufnehmen
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run pruefen` muss vor jedem Commit grün sein. Fehlt `shellcheck` lokal,
|
||||||
|
wird das Docker-Abbild `koalaman/shellcheck` benutzt.
|
||||||
|
|
||||||
|
### Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
prisma/ Schema, Migrationen, Seed, Demo-Seed
|
||||||
|
scripts/ Prüf-, Test- und Betriebsskripte
|
||||||
|
src/app/ Seiten und API-Routen; (app) = angemeldeter Bereich
|
||||||
|
src/components/ Bausteine; ui/ enthält die Primitiven
|
||||||
|
src/lib/ Reine Logik ohne DB und ohne Systemzeit, je Datei ein Test
|
||||||
|
src/server/ DB-nahe Dienste, Server Actions, Provider
|
||||||
|
e2e/ Playwright-Rauchtests
|
||||||
|
screenshots/ Aufnahme der Handbuchbilder
|
||||||
|
docs/ Schnellstart, Handbuch, Bilder
|
||||||
|
devlog/ Entwicklungstagebuch und Zeiterfassung
|
||||||
|
```
|
||||||
|
|
||||||
|
Reine Funktionen bekommen die aktuelle Zeit als Parameter, damit sie ohne
|
||||||
|
Tricks prüfbar bleiben. Die Tests laufen bewusst in UTC — so fällt sofort auf,
|
||||||
|
wenn irgendwo die Ortszeit des Servers durchschlägt statt Europe/Berlin.
|
||||||
|
|
||||||
|
### Bekannte Meldungen
|
||||||
|
|
||||||
|
`npm audit` meldet vier Funde in transitiven Abhängigkeiten der
|
||||||
|
**`prisma`-CLI** (`deepmerge-ts` über `@prisma/config`, `mysql2`). Beide sind
|
||||||
|
Entwicklungsabhängigkeiten und erreichen das Laufzeitabbild nicht; MySQL wird
|
||||||
|
ohnehin nie geladen. Der angebotene Fix wäre ein Rückschritt auf Prisma 6.
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
- Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit
|
||||||
|
sich nicht abfragen lässt, wer hier ein Konto hat.
|
||||||
|
- Sitzungen sind JWT, aber Rolle und Sperrstatus werden bei jeder Anfrage
|
||||||
|
frisch aus der Datenbank gelesen — eine Sperre wirkt sofort.
|
||||||
|
- Jede schreibende Aktion prüft die Rolle in der Seite **und** noch einmal in
|
||||||
|
der Server Action. Die Oberfläche blendet Knöpfe nur zusätzlich aus.
|
||||||
|
- Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach
|
||||||
|
Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt.
|
||||||
|
- Keine Geheimnisse, Tokens, Mailinhalte oder Termin-Betreffs in Protokollen.
|
||||||
|
|
||||||
|
Gefundene Schwachstellen bitte nicht als Issue anlegen, sondern direkt melden.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Privates Projekt, keine Lizenz zur Weiterverwendung.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Fahrplan
|
||||||
|
|
||||||
|
Acht Meilensteine. Jeder endet mit einer lauffähigen Version auf `main`,
|
||||||
|
grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||||
|
|
||||||
|
| Meilenstein | Ziel | Status |
|
||||||
|
| --------------------------------------- | ---------------------------- | --------- |
|
||||||
|
| [M1](#m1--fundament) | Fundament | in Arbeit |
|
||||||
|
| [M2](#m2--aufgaben-und-projekte) | Aufgaben und Projekte | geplant |
|
||||||
|
| [M3](#m3--kalender-lesen) | Kalender lesen | geplant |
|
||||||
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | geplant |
|
||||||
|
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | geplant |
|
||||||
|
| [M6](#m6--integrationen) | Integrationen | geplant |
|
||||||
|
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||||
|
| [M8](#m8--mcp) | MCP | geplant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 – Fundament
|
||||||
|
|
||||||
|
**Ziel:** Ein angemeldeter, betreibbarer Rumpf. Noch keine Aufgaben, kein
|
||||||
|
Kalender — aber alles, worauf die späteren Meilensteine aufsetzen.
|
||||||
|
|
||||||
|
- [x] Projektgerüst: Next 16, React 19, TypeScript strict, Tailwind 4
|
||||||
|
- [x] `npm run pruefen`: Prettier, ESLint, tsc, Vitest, ShellCheck
|
||||||
|
- [x] Datenmodell für Anmeldung, Passkeys, Bereiche und Audit
|
||||||
|
- [x] Anmeldung über OIDC, Passkey und wahlweise Passwort
|
||||||
|
- [x] Rollen `BENUTZER` und `ADMIN`, serverseitig in Seite **und** Server Action
|
||||||
|
- [x] Audit-Eintrag je schreibender Aktion, Details werden gesäubert
|
||||||
|
- [x] Instanzsteuerung `INSTANZ=firma|daheim` mit 404 statt Hinweis
|
||||||
|
- [x] Bereiche verwalten: Farbe, Arbeitsfenster, Tageskapazität
|
||||||
|
- [x] Protokollseite für die Verwaltung
|
||||||
|
- [x] `/api/health` mit Status, Version und Instanz
|
||||||
|
- [x] Dockerfile, Compose-Stapel mit eigenem Migrationsdienst
|
||||||
|
- [x] `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
|
||||||
|
- [x] Unit-, Integrations-, Migrations- und E2E-Tests
|
||||||
|
- [x] Screenshot-Kette mit eigener Wegwerf-Instanz
|
||||||
|
- [x] README, Schnellstart, Handbuch, CHANGELOG, DEVLOG
|
||||||
|
|
||||||
|
**Bewusst verschoben:** `Aufgabe` und `Zeitblock` kommen mit M2 und M4,
|
||||||
|
`Kalenderkonto` mit M3 — jeweils mit den Feldern, die der Provider dann
|
||||||
|
wirklich braucht, statt heute geraten.
|
||||||
|
|
||||||
|
## M2 – Aufgaben und Projekte
|
||||||
|
|
||||||
|
**Ziel:** Alles erfassen und wiederfinden, noch ohne Kalender.
|
||||||
|
|
||||||
|
- [ ] `Aufgabe` mit Typ, Status, Priorität, Aufwand, Fälligkeit, Quelle
|
||||||
|
- [ ] Eindeutigkeit `[quelle, quelleRef]`, Integrationen upserten
|
||||||
|
- [ ] Inbox: schnell erfassen, später einsortieren
|
||||||
|
- [ ] Ideen getrennt von Aufgaben
|
||||||
|
- [ ] Projekte mit Arbeitspaketen und Meilensteinen
|
||||||
|
- [ ] Listen mit Filtern nach Bereich, Status und Fälligkeit
|
||||||
|
- [ ] Handbuchseiten und Bilder
|
||||||
|
|
||||||
|
## M3 – Kalender lesen
|
||||||
|
|
||||||
|
**Ziel:** Termine aus Microsoft 365 und Google sehen.
|
||||||
|
|
||||||
|
- [ ] `Kalenderkonto` mit Anbieter, Tokens und Delta-Verweis
|
||||||
|
- [ ] OAuth-Verbindung je Instanz: Graph immer, Google nur daheim
|
||||||
|
- [ ] Graph-Delta-Sync und Google-Sync über `fetch`, Antworten mit zod geprüft
|
||||||
|
- [ ] Ganztägige Termine zählen nicht zur Belegung
|
||||||
|
- [ ] Anonymisierte Beispielantworten in `test/fixtures/`
|
||||||
|
- [ ] Hintergrundjob mit node-cron
|
||||||
|
|
||||||
|
## M4 – Wochenplanung und Zeitblöcke
|
||||||
|
|
||||||
|
**Ziel:** Aufgaben per Ziehen und Ablegen in den Kalender schreiben — und
|
||||||
|
Verschiebungen von dort zurückholen.
|
||||||
|
|
||||||
|
- [ ] `Zeitblock` an genau einer Aufgabe und einem Kalenderkonto
|
||||||
|
- [ ] Wochenansicht mit `@dnd-kit/core`
|
||||||
|
- [ ] Schreiben in den Kalender, Rückweg beim Verschieben
|
||||||
|
- [ ] Schleifenschutz über `hash = sha256(startISO|endeISO)`
|
||||||
|
- [ ] Konflikte: der Kalender gewinnt (HTTP 412 auf If-Match)
|
||||||
|
- [ ] Belegung gegen die Tageskapazität des Bereichs
|
||||||
|
|
||||||
|
## M5 – Benachrichtigungen
|
||||||
|
|
||||||
|
- [ ] Web-Push mit `web-push`
|
||||||
|
- [ ] Tagesüberblick per E-Mail über nodemailer
|
||||||
|
- [ ] Erinnerungen an Fälligkeiten, Jobs über node-cron
|
||||||
|
- [ ] Je Benutzer einstellbar, mit Ruhezeiten
|
||||||
|
|
||||||
|
## M6 – Integrationen
|
||||||
|
|
||||||
|
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
|
||||||
|
- [ ] Telegram (nur `daheim`)
|
||||||
|
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
|
||||||
|
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
|
||||||
|
|
||||||
|
## M7 – Schnellerfassung und API
|
||||||
|
|
||||||
|
- [ ] Erfassung vom Handy, auch offline
|
||||||
|
- [ ] Öffentliche API mit Token je Anwendung
|
||||||
|
- [ ] Rechte je Token, jede Nutzung im Protokoll
|
||||||
|
|
||||||
|
## M8 – MCP
|
||||||
|
|
||||||
|
- [ ] MCP-Server über `@modelcontextprotocol/sdk`
|
||||||
|
- [ ] Lesen und Anlegen von Aufgaben und Zeitblöcken
|
||||||
|
- [ ] Rechte wie bei der API, jede Nutzung im Protokoll
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideen für später
|
||||||
|
|
||||||
|
- Auswertung: wie viel Zeit ging tatsächlich in welchen Bereich
|
||||||
|
- Wiederkehrende Aufgaben mit eigenem Rhythmus
|
||||||
|
- Vorlagen für wiederkehrende Projekte
|
||||||
|
- Verknüpfung von Aufgaben untereinander (blockiert, gehört zu)
|
||||||
|
- Export der eigenen Daten als JSON
|
||||||
|
|
||||||
|
## Bewusst nicht geplant
|
||||||
|
|
||||||
|
- **Wiederkehrende Wartungsaufgaben mit Nachweispflicht.** Die bleiben im
|
||||||
|
Wartungsplaner. Der Planer liest nur Fälligkeiten über dessen API.
|
||||||
|
- **Mehrbenutzerbetrieb mit geteilten Aufgaben.** Der Planer ist für eine
|
||||||
|
Person gedacht; Kollegen melden sich in der Firmeninstanz nur an ihren
|
||||||
|
eigenen Daten an.
|
||||||
|
- **Eigene Kalenderdarstellung als Ersatz für Outlook.** Der Planer schreibt
|
||||||
|
in die vorhandenen Kalender, er ersetzt sie nicht.
|
||||||
|
- **Zeiterfassung zur Abrechnung.** Dafür gibt es andere Werkzeuge.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sichert Datenbank, .env und Uploads des Planers.
|
||||||
|
# Wiederherstellung: siehe README.md, Abschnitt "Sicherung und Wiederherstellung".
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$WURZEL"
|
||||||
|
|
||||||
|
ZIEL="${WURZEL}/sicherungen"
|
||||||
|
AUFBEWAHRUNG_TAGE=30
|
||||||
|
NUR_DATENBANK=0
|
||||||
|
|
||||||
|
hilfe() {
|
||||||
|
cat <<'ENDE'
|
||||||
|
Verwendung: backup.sh [Optionen]
|
||||||
|
|
||||||
|
--ziel <Pfad> Ablageort der Sicherung (Standard: ./sicherungen)
|
||||||
|
--tage <Anzahl> Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt)
|
||||||
|
--nur-datenbank Nur den Datenbank-Dump erzeugen
|
||||||
|
--hilfe Diese Übersicht
|
||||||
|
|
||||||
|
Erzeugt je Lauf zwei Dateien:
|
||||||
|
planer-db-<Zeitstempel>.dump Datenbank im Custom-Format von pg_dump
|
||||||
|
planer-dateien-<Zeitstempel>.tar.gz .env und der Ordner uploads
|
||||||
|
ENDE
|
||||||
|
}
|
||||||
|
|
||||||
|
meldung() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; }
|
||||||
|
fehler() {
|
||||||
|
printf '%s Fehler: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--ziel)
|
||||||
|
ZIEL="${2:-}"
|
||||||
|
[ -n "$ZIEL" ] || fehler '--ziel braucht einen Pfad.'
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tage)
|
||||||
|
AUFBEWAHRUNG_TAGE="${2:-}"
|
||||||
|
case "$AUFBEWAHRUNG_TAGE" in
|
||||||
|
''|*[!0-9]*) fehler '--tage braucht eine ganze Zahl.' ;;
|
||||||
|
esac
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--nur-datenbank)
|
||||||
|
NUR_DATENBANK=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--hilfe|-h)
|
||||||
|
hilfe
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) fehler "Unbekannte Option '$1'. 'backup.sh --hilfe' zeigt die Übersicht." ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || fehler 'docker fehlt.'
|
||||||
|
[ -f "$WURZEL/.env" ] || fehler '.env fehlt – im Projektverzeichnis aufrufen.'
|
||||||
|
|
||||||
|
# Nur die benötigten Werte lesen, nicht die ganze Datei ausführen.
|
||||||
|
lies_env() {
|
||||||
|
local schluessel="$1" standard="${2:-}"
|
||||||
|
local wert
|
||||||
|
wert="$(grep -E "^${schluessel}=" "$WURZEL/.env" | tail -1 | cut -d= -f2- || true)"
|
||||||
|
wert="${wert%\"}"
|
||||||
|
wert="${wert#\"}"
|
||||||
|
printf '%s' "${wert:-$standard}"
|
||||||
|
}
|
||||||
|
|
||||||
|
PG_BENUTZER="$(lies_env POSTGRES_USER planer)"
|
||||||
|
PG_DATENBANK="$(lies_env POSTGRES_DB planer)"
|
||||||
|
|
||||||
|
DB_CONTAINER="$(docker compose ps -q db)"
|
||||||
|
[ -n "$DB_CONTAINER" ] || fehler 'Der Datenbank-Container läuft nicht. Erst "docker compose up -d db".'
|
||||||
|
|
||||||
|
ZEITSTEMPEL="$(date '+%Y-%m-%d_%H-%M-%S')"
|
||||||
|
mkdir -p "$ZIEL"
|
||||||
|
|
||||||
|
DUMP="$ZIEL/planer-db-${ZEITSTEMPEL}.dump"
|
||||||
|
meldung "Datenbank sichern nach $DUMP"
|
||||||
|
# -Fc ist das Custom-Format: komprimiert und mit pg_restore selektiv einspielbar.
|
||||||
|
docker exec "$DB_CONTAINER" pg_dump -U "$PG_BENUTZER" -d "$PG_DATENBANK" -Fc > "$DUMP"
|
||||||
|
|
||||||
|
if [ ! -s "$DUMP" ]; then
|
||||||
|
rm -f "$DUMP"
|
||||||
|
fehler 'Der Dump ist leer geblieben.'
|
||||||
|
fi
|
||||||
|
meldung "Datenbank gesichert ($(du -h "$DUMP" | cut -f1))."
|
||||||
|
|
||||||
|
if [ "$NUR_DATENBANK" -eq 0 ]; then
|
||||||
|
DATEIEN="$ZIEL/planer-dateien-${ZEITSTEMPEL}.tar.gz"
|
||||||
|
meldung "Dateien sichern nach $DATEIEN"
|
||||||
|
ZU_SICHERN=(.env)
|
||||||
|
[ -d "$WURZEL/uploads" ] && ZU_SICHERN+=(uploads)
|
||||||
|
tar -czf "$DATEIEN" -C "$WURZEL" "${ZU_SICHERN[@]}"
|
||||||
|
meldung "Dateien gesichert ($(du -h "$DATEIEN" | cut -f1))."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AUFBEWAHRUNG_TAGE" -gt 0 ]; then
|
||||||
|
meldung "Sicherungen älter als $AUFBEWAHRUNG_TAGE Tage entfernen."
|
||||||
|
find "$ZIEL" -maxdepth 1 -type f -name 'planer-*' -mtime "+$AUFBEWAHRUNG_TAGE" -print -delete
|
||||||
|
fi
|
||||||
|
|
||||||
|
meldung 'Sicherung abgeschlossen.'
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Entwicklungstagebuch
|
||||||
|
|
||||||
|
Neuester Eintrag oben. Die Dauer je Sitzung stammt aus `devlog/zeiten.csv`,
|
||||||
|
die Übersicht aus `scripts/zeit.sh summe`.
|
||||||
|
|
||||||
|
## Zeit je Meilenstein
|
||||||
|
|
||||||
|
| Meilenstein | Art | Minuten | Stunden |
|
||||||
|
| ----------- | ------ | ------- | -------- |
|
||||||
|
| M1 | claude | 161 | 2,68 |
|
||||||
|
| **Gesamt** | | **161** | **2,68** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-10 – M1: Fundament
|
||||||
|
|
||||||
|
**Dauer:** 161 Minuten (12:06–14:47), Art `claude`.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Das Projekt von Grund auf aufsetzen: Gerüst,
|
||||||
|
Datenbank, Anmeldung, Rollen, Audit, Instanzsteuerung, Betrieb und
|
||||||
|
Dokumentation.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Projektgerüst mit Next 16, React 19, TypeScript strict, Tailwind 4,
|
||||||
|
alle Versionen exakt gepinnt
|
||||||
|
- `npm run pruefen` als Sammelprüfung: Prettier, ESLint, `tsc`, Vitest,
|
||||||
|
ShellCheck
|
||||||
|
- Datenmodell und erste Migration: Benutzer mit Rolle, OIDC-Konten,
|
||||||
|
Passkeys, WebAuthn-Herausforderungen, Bereiche, Audit
|
||||||
|
- Anmeldung über OIDC, Passkey und Passwort, je Instanz zuschaltbar
|
||||||
|
- Rollenprüfung in Seite **und** Server Action, Audit je Schreibaktion
|
||||||
|
- Instanzsteuerung mit 404 statt Hinweis
|
||||||
|
- Bereiche verwalten, Protokollseite, Startseite, Einstellungen
|
||||||
|
- `/api/health`, Dockerfile, Compose-Stapel mit Migrationsdienst
|
||||||
|
- `backup.sh`, `update.sh`, `scripts/release.sh`, `scripts/zeit.sh`
|
||||||
|
- Unit-, Integrations-, Migrations- und E2E-Tests
|
||||||
|
- Screenshot-Kette mit eigener Wegwerf-Instanz
|
||||||
|
- README, Schnellstart, Handbuch, ROADMAP, CHANGELOG
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Prisma 7.10.0 statt der notierten Fassung 6.** Fassung 6 ist überholt,
|
||||||
|
Fassung 8 liegt nur als RC auf `latest`. Prisma 7 nimmt die Verbindung
|
||||||
|
nicht mehr im Schema entgegen — sie steht in `prisma.config.ts`, der
|
||||||
|
Client bekommt den pg-Treiberadapter.
|
||||||
|
|
||||||
|
**TypeScript 6.0.3 statt 7.0.2.** TS 7 ist zwar `latest`, aber
|
||||||
|
`typescript-eslint@8.70` verlangt `typescript >=4.8.4 <6.1.0`. Mit TS 7
|
||||||
|
wäre die Lint-Kette kaputt gewesen.
|
||||||
|
|
||||||
|
**ESLint 9.39.5 statt 10.** Das in `eslint-config-next@16` gebündelte
|
||||||
|
`eslint-plugin-react` ruft noch `context.getFilename()`, das ESLint 10
|
||||||
|
entfernt hat. Der Lint-Lauf brach reproduzierbar ab.
|
||||||
|
|
||||||
|
**Eigener Auth.js-Adapter.** Der mitgelieferte `@auth/prisma-adapter`
|
||||||
|
greift fest auf `prisma.user` und `prisma.account` zu; unsere Modelle
|
||||||
|
heißen `Benutzer` und `Konto`. Der Adapter ist schmal und ersparte es,
|
||||||
|
die Fachbegriffe der Konvention zu opfern.
|
||||||
|
|
||||||
|
**Passkeys direkt über SimpleWebAuthn 14.** Der WebAuthn-Provider von
|
||||||
|
Auth.js ist als experimentell markiert und auf SimpleWebAuthn 9
|
||||||
|
festgelegt. Direkt angebunden bleibt die Fassung aktuell und der Ablauf
|
||||||
|
nachvollziehbar. Für die npm-Auflösung brauchte es `overrides`, weil
|
||||||
|
next-auth die älteren Fassungen als optionale Peers deklariert.
|
||||||
|
|
||||||
|
**Sitzungen als JWT, Rolle trotzdem aus der Datenbank.** Credentials-
|
||||||
|
Provider verlangen JWT. Damit eine Sperre nicht bis zum Ablauf des Tokens
|
||||||
|
wirkungslos bleibt, liest der `session`-Callback Rolle und Status bei
|
||||||
|
jeder Anfrage frisch. Die Tabellen `sitzung` und `verifizierungs_token`
|
||||||
|
wären damit tot und wurden wieder entfernt, solange es nur eine einzige
|
||||||
|
Migration gab.
|
||||||
|
|
||||||
|
**Migrationen als eigener Compose-Dienst.** Der Versuch, nur die
|
||||||
|
Prisma-CLI ins schlanke Laufzeitabbild zu kopieren, scheiterte an ihren
|
||||||
|
transitiven Modulen (`effect` über `@prisma/config`). Alle mitzunehmen
|
||||||
|
hätte das Abbild aufgebläht. Ein Einmaldienst aus der Bau-Stufe läuft vor
|
||||||
|
der App und muss sauber durchlaufen.
|
||||||
|
|
||||||
|
**Aufgabe, Zeitblock und Kalenderkonto bewusst nicht modelliert.** Sie
|
||||||
|
kommen mit M2, M4 und M3 — dann mit den Feldern, die der jeweilige
|
||||||
|
Provider wirklich braucht, statt heute geraten.
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `prisma validate` lehnte `url` im Schema ab | Prisma 7 erwartet die Verbindung in `prisma.config.ts` und einen Treiberadapter im Client. |
|
||||||
|
| Integrationstest brach nach der ersten erwarteten Verletzung ab | In PostgreSQL bricht ein fehlgeschlagener Befehl die ganze Transaktion ab. Der Test läuft jetzt ohne Sammeltransaktion; alle Testdaten tragen eine Kennung und werden am Ende entfernt. |
|
||||||
|
| `prisma migrate diff` kannte `--from-url` nicht | In Prisma 7 heißen die Flaggen anders. Der Test vergleicht jetzt die eingespielte Wegwerf-Datenbank über `--from-config-datasource` gegen das Schema — und braucht damit keine Schattendatenbank. |
|
||||||
|
| Nach der Anmeldung ging es nicht zum ursprünglichen Ziel zurück | Das Layout leitet um, bevor die Seite drankommt, und kennt seinen Pfad nicht. Ein schlanker Proxy reicht ihn als Kopfzeile durch — ohne Anmeldelogik, weil er im Edge-Umfeld läuft. |
|
||||||
|
| Der Docker-Bau brach beim Sammeln der Seitendaten ab | Prisma-Client und Auth.js-Konfiguration wurden beim Laden des Moduls ausgewertet und brauchten Umgebungsvariablen, die es zur Bauzeit nicht gibt. Beide entstehen jetzt faul. |
|
||||||
|
| Ein Typfehler in `scripts/screenshots.ts` blockierte den Abbild-Bau | Entwicklungsskripte gehören nicht in den Docker-Kontext. `.dockerignore` schließt sie aus; geprüft werden sie von `npm run pruefen`. |
|
||||||
|
| ESLint beanstandete `setState` im Effekt | Der Hydratations-Trick im Farbschema-Knopf war unnötig: `next-themes` setzt die `dark`-Klasse vor der Hydratation, das erledigt CSS ohne Zustand. |
|
||||||
|
|
||||||
|
### Eigene Fehler, die ich korrigiert habe
|
||||||
|
|
||||||
|
- Die ersten vier Commits lagen direkt auf `main` statt auf einem
|
||||||
|
Meilenstein-Zweig und ohne Attributionszeile. Historie umgebaut:
|
||||||
|
`main` trägt nur noch einen leeren Wurzel-Commit, die Arbeit liegt auf
|
||||||
|
`m1-fundament`.
|
||||||
|
- Ein `git add -A` zog die Proxy-Umbenennung in den Doku-Commit. Wieder
|
||||||
|
getrennt.
|
||||||
|
- `scripts/screenshots.ts` wurde geschrieben, ohne danach `npm run
|
||||||
|
pruefen` laufen zu lassen; der Typfehler fiel erst im Docker-Bau auf.
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- Die `origin`-URL fehlt noch. Push und Merge stehen ohnehin erst nach
|
||||||
|
der Freigabe an.
|
||||||
|
- Der Schnellstart endet beim Passkey statt beim ersten Kalenderkonto und
|
||||||
|
der ersten Aufgabe — beides gibt es erst mit M3 und M2.
|
||||||
|
- Der Demo-Seed enthält noch keine erfundenen Termine; einen
|
||||||
|
Demo-Kalenderprovider gibt es erst mit M3.
|
||||||
|
- Lokal weichen die Ports ab (Datenbank 5435, App 3001), weil 5432 und
|
||||||
|
3000 auf diesem Rechner belegt sind. In `.env.example` stehen die
|
||||||
|
üblichen Werte.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- de1f923 chore: Projektgerüst und Werkzeugkette einrichten
|
||||||
|
- 748d032 feat(datenbank): Datenmodell für Anmeldung, Bereiche und Audit
|
||||||
|
- e2b00f4 feat(instanz): Instanzsteuerung mit serverseitigem Riegel
|
||||||
|
- 19ca082 feat(zeit): Zeitrechnung und Anzeigeformate in Europe/Berlin
|
||||||
|
- 4ff67c1 feat(oberflaeche): Grundlayout, Navigation und Farbschema
|
||||||
|
- cc013fd feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5
|
||||||
|
- e9a2b4b feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
|
||||||
|
- 1876ae4 feat(betrieb): Health-Endpunkt und Seed für eine frische Datenbank
|
||||||
|
- 24a799d chore: tsconfig-Anpassungen von Next 16 übernehmen
|
||||||
|
- 126ad93 test: Integrations-, Migrations- und E2E-Tests ergänzen
|
||||||
|
- f91d015 feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte
|
||||||
|
- 56b5356 docs: README, Schnellstart, Handbuch, Fahrplan und CHANGELOG
|
||||||
|
- e3390c8 refactor: middleware in proxy umbenennen
|
||||||
|
- a882731 chore: Screenshot-Kette und Demo-Seed
|
||||||
|
- cf28ec1 test: Rollentrennung end-to-end absichern
|
||||||
|
- 6d04bc2 fix(oberflaeche): Kopfleiste einzeilig halten, Demo-Uhr feststellen
|
||||||
|
- 688d9f5 docs: Handbuchbilder aufnehmen
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
datum;start;ende;minuten;meilenstein;art;notiz
|
||||||
|
2026-09-10;12:06;14:47;161;M1;claude;M1 Fundament fertiggestellt
|
||||||
|
@@ -0,0 +1,61 @@
|
|||||||
|
# Wegwerf-Instanz nur für "npm run screenshots".
|
||||||
|
#
|
||||||
|
# Eigenes Compose-Projekt (planer-screenshots), eigenes Volume, eigene Ports.
|
||||||
|
# Sie kommt der Entwicklungs- oder Betriebsinstanz damit nicht in die Quere und
|
||||||
|
# wird nach dem Lauf samt Volume wieder entfernt.
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: 'no'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: demo
|
||||||
|
POSTGRES_PASSWORD: demo
|
||||||
|
POSTGRES_DB: demo
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
ports:
|
||||||
|
- '${SCREENSHOT_DB_PORT:-5455}:5432'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U demo -d demo']
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
migration:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: bauen
|
||||||
|
restart: 'no'
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
|
||||||
|
command: node node_modules/prisma/build/index.js migrate deploy
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: betrieb
|
||||||
|
restart: 'no'
|
||||||
|
depends_on:
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
INSTANZ: daheim
|
||||||
|
APP_URL: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||||
|
DATABASE_URL: postgresql://demo:demo@db:5432/demo?schema=public
|
||||||
|
AUTH_SECRET: nur-fuer-die-screenshot-instanz-nicht-geheim
|
||||||
|
AUTH_TRUST_HOST: 'true'
|
||||||
|
ANMELDUNG_PASSWORT: 'true'
|
||||||
|
WEBAUTHN_RP_ID: 127.0.0.1
|
||||||
|
WEBAUTHN_RP_NAME: Planer
|
||||||
|
WEBAUTHN_ORIGIN: http://127.0.0.1:${SCREENSHOT_APP_PORT:-3200}
|
||||||
|
DEMO_MODUS: 'true'
|
||||||
|
ports:
|
||||||
|
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: planer-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-planer}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-planer}
|
||||||
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
|
volumes:
|
||||||
|
- db-daten:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- '${POSTGRES_PORT:-5432}:5432'
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planer} -d ${POSTGRES_DB:-planer}']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
# Einmaldienst: spielt die Migrationen ein und beendet sich. Läuft aus der
|
||||||
|
# Bau-Stufe, weil die Prisma-CLI eine Reihe Module braucht, die im schlanken
|
||||||
|
# Laufzeitabbild nichts zu suchen haben.
|
||||||
|
migration:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: bauen
|
||||||
|
container_name: planer-migration
|
||||||
|
restart: 'no'
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
|
||||||
|
command: node node_modules/prisma/build/index.js migrate deploy
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: betrieb
|
||||||
|
container_name: planer-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
TZ: ${TZ:-Europe/Berlin}
|
||||||
|
INSTANZ: ${INSTANZ:?INSTANZ fehlt in .env}
|
||||||
|
APP_URL: ${APP_URL:?APP_URL fehlt in .env}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in .env}
|
||||||
|
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||||
|
ANMELDUNG_PASSWORT: ${ANMELDUNG_PASSWORT:-false}
|
||||||
|
OIDC_NAME: ${OIDC_NAME:-}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER:-}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
|
||||||
|
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
|
||||||
|
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
|
||||||
|
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
|
||||||
|
# Der Demo-Modus ist im Betrieb nie erlaubt.
|
||||||
|
DEMO_MODUS: 'false'
|
||||||
|
ports:
|
||||||
|
- '${APP_PORT:-3000}:3000'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-daten:
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Schnellstart
|
||||||
|
|
||||||
|
Von `git clone` bis zur ersten Anmeldung in höchstens zehn Minuten.
|
||||||
|
Jeder Befehl ist zum Kopieren, jeder Schritt nennt das erwartete Ergebnis.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
| Werkzeug | Fassung | Prüfen mit |
|
||||||
|
| ------------------ | ---------------- | ------------------------ |
|
||||||
|
| Docker mit Compose | 25 oder neuer | `docker compose version` |
|
||||||
|
| Git | 2.40 oder neuer | `git --version` |
|
||||||
|
| Node.js | 22.12 oder neuer | `node -v` |
|
||||||
|
|
||||||
|
Node wird nur für die Entwicklung gebraucht. Für den reinen Betrieb genügen
|
||||||
|
Docker und Git.
|
||||||
|
|
||||||
|
## 1. Holen und Verzeichnis wechseln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <REMOTE_URL> planer
|
||||||
|
cd planer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartet:** Das Verzeichnis `planer` enthält unter anderem `package.json`,
|
||||||
|
`docker-compose.yml` und `.env.example`.
|
||||||
|
|
||||||
|
## 2. Konfiguration anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Jetzt die Pflichtwerte setzen. Die Geheimnisse werden erzeugt, nicht erfunden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Signiert die Sitzungen. Ohne diesen Wert startet die Anwendung nicht.
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
|
# Ein Passwort für die Datenbank.
|
||||||
|
openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
Unter Windows ohne OpenSSL geht auch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Werte in `.env` eintragen:
|
||||||
|
|
||||||
|
| Variable | Beispiel | Bedeutung |
|
||||||
|
| ------------------- | -------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `INSTANZ` | `daheim` | `firma` oder `daheim` – bestimmt, was es überhaupt gibt |
|
||||||
|
| `APP_URL` | `http://localhost:3000` | Adresse ohne Schrägstrich am Ende |
|
||||||
|
| `AUTH_SECRET` | _(erzeugt)_ | Signatur der Sitzungen |
|
||||||
|
| `POSTGRES_PASSWORD` | _(erzeugt)_ | Passwort der Datenbank |
|
||||||
|
| `DATABASE_URL` | `postgresql://planer:<Passwort>@localhost:5432/planer?schema=public` | muss dasselbe Passwort enthalten |
|
||||||
|
| `WEBAUTHN_RP_ID` | `localhost` | nackte Domain, ohne Schema und Port |
|
||||||
|
| `WEBAUTHN_ORIGIN` | `http://localhost:3000` | exakt wie im Browser, mit Port |
|
||||||
|
| `ADMIN_EMAIL` | `du@example.de` | erstes Verwaltungskonto |
|
||||||
|
| `ADMIN_PASSWORT` | _(mindestens 12 Zeichen)_ | nur nötig, wenn du dich ohne OIDC anmelden willst |
|
||||||
|
|
||||||
|
> **Sind die Ports 5432 oder 3000 auf deinem Rechner belegt?**
|
||||||
|
> Dann `POSTGRES_PORT` beziehungsweise `APP_PORT` in `.env` auf einen freien
|
||||||
|
> Wert setzen. `DATABASE_URL`, `APP_URL` und `WEBAUTHN_ORIGIN` müssen denselben
|
||||||
|
> Port nennen.
|
||||||
|
|
||||||
|
**Erwartet:** `.env` liegt im Projektverzeichnis. Sie gehört niemals ins Git —
|
||||||
|
`.gitignore` sorgt dafür.
|
||||||
|
|
||||||
|
## 3. Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Mal dauert der Bau einige Minuten. Der Ablauf ist: Datenbank
|
||||||
|
starten, Migrationen einspielen, Anwendung starten.
|
||||||
|
|
||||||
|
**Erwartet:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
zeigt `db` als `healthy` und `app` als `Up`. Der Dienst `migration` taucht
|
||||||
|
nicht mehr auf — er hat seine Arbeit getan und sich beendet.
|
||||||
|
|
||||||
|
## 4. Prüfen, ob alles läuft
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartet:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "version": "0.1.0", "instanz": "daheim", "datenbank": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Steht dort `"datenbank": "fehler"`, stimmt `DATABASE_URL` nicht mit
|
||||||
|
`POSTGRES_PASSWORD` überein.
|
||||||
|
|
||||||
|
## 5. Erste Bereiche und das Verwaltungskonto anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm migration node node_modules/prisma/build/index.js db seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erwartet:** Die Ausgabe nennt die vier angelegten Bereiche (Firma, Gewerbe,
|
||||||
|
Privat, Homelab) und bestätigt das Verwaltungskonto.
|
||||||
|
|
||||||
|
## 6. Anmelden
|
||||||
|
|
||||||
|
Im Browser <http://localhost:3000> öffnen.
|
||||||
|
|
||||||
|
**Erwartet:** Die Anmeldeseite. Mit `ADMIN_EMAIL` und `ADMIN_PASSWORT`
|
||||||
|
anmelden — danach steht oben „Guten Tag" und darunter die vier Bereiche.
|
||||||
|
|
||||||
|
> Ist OIDC eingerichtet, erscheint zusätzlich ein Knopf mit dem Namen aus
|
||||||
|
> `OIDC_NAME`. Der Rückleit-URI, den du beim Anbieter hinterlegen musst,
|
||||||
|
> lautet `<APP_URL>/api/auth/callback/oidc`.
|
||||||
|
|
||||||
|
## 7. Einen Passkey hinterlegen
|
||||||
|
|
||||||
|
Auf **Einstellungen** gehen, unter _Passkeys_ einen Namen für das Gerät
|
||||||
|
eintragen (etwa „Arbeitsrechner") und auf **Anlegen** klicken. Das Gerät
|
||||||
|
fragt nach Fingerabdruck, Gesicht oder PIN.
|
||||||
|
|
||||||
|
**Erwartet:** Der Passkey steht in der Liste. Ab jetzt genügt zum Anmelden die
|
||||||
|
E-Mail-Adresse und **Mit Passkey anmelden**.
|
||||||
|
|
||||||
|
> Hinterlege am besten zwei Passkeys auf verschiedenen Geräten. Sonst sperrst
|
||||||
|
> du dich aus, wenn ein Gerät verlorengeht.
|
||||||
|
|
||||||
|
## 8. Aufräumen
|
||||||
|
|
||||||
|
Wenn `ADMIN_PASSWORT` nur zur Einrichtung gedient hat, kannst du es jetzt
|
||||||
|
zusammen mit `ADMIN_EMAIL` aus `.env` entfernen. Das Konto bleibt bestehen.
|
||||||
|
|
||||||
|
## Wie es weitergeht
|
||||||
|
|
||||||
|
- [Handbuch](handbuch/) – Anleitung für den Alltag
|
||||||
|
- [README](../README.md) – alle Umgebungsvariablen, Betrieb, Sicherung
|
||||||
|
- [ROADMAP](../ROADMAP.md) – was in den nächsten Meilensteinen kommt
|
||||||
|
|
||||||
|
## Wenn etwas klemmt
|
||||||
|
|
||||||
|
| Symptom | Ursache und Abhilfe |
|
||||||
|
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `INSTANZ fehlt in .env` beim Start | `INSTANZ` ist leer. Erlaubt sind nur `firma` und `daheim`. |
|
||||||
|
| `Die Konfiguration ist unvollständig` | Die Meldung nennt die fehlende Variable. Sie nennt nie den Wert. |
|
||||||
|
| `port is already allocated` | Ein anderer Dienst hält den Port. `APP_PORT` oder `POSTGRES_PORT` ändern. |
|
||||||
|
| Passkey lässt sich nicht anlegen | `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. `WEBAUTHN_RP_ID` ist die nackte Domain, ohne `https://`. |
|
||||||
|
| Anmeldung schlägt immer fehl | Bewusst gibt es nur eine Meldung für alle Fälle. Prüfe im Protokoll der Anwendung: `docker compose logs app`. |
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 102 KiB |
@@ -0,0 +1,36 @@
|
|||||||
|
# Handbuch
|
||||||
|
|
||||||
|
Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
1. **[Anmelden](anmelden.md)** – die drei Wege hinein, Passkeys einrichten,
|
||||||
|
was tun, wenn nichts mehr geht
|
||||||
|
2. **[Bereiche einrichten](bereiche.md)** – Farbe, Arbeitsfenster und
|
||||||
|
Tageskapazität festlegen
|
||||||
|
|
||||||
|
## Verwaltung
|
||||||
|
|
||||||
|
3. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
|
## Was noch kommt
|
||||||
|
|
||||||
|
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||||
|
|
||||||
|
| Thema | Meilenstein |
|
||||||
|
| ------------------------------- | ----------- |
|
||||||
|
| Aufgaben erfassen, Inbox, Ideen | M2 |
|
||||||
|
| Projekte und Arbeitspakete | M2 |
|
||||||
|
| Kalenderkonten verbinden | M3 |
|
||||||
|
| Wochenplanung und Zeitblöcke | M4 |
|
||||||
|
| Benachrichtigungen | M5 |
|
||||||
|
| Integrationen | M6 |
|
||||||
|
| Schnellerfassung vom Handy, API | M7 |
|
||||||
|
| MCP | M8 |
|
||||||
|
|
||||||
|
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||||
|
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Anmelden
|
||||||
|
|
||||||
|
Es gibt drei Wege in den Planer. Welche davon zu sehen sind, hängt davon ab,
|
||||||
|
was für deine Instanz eingerichtet wurde.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Weg | Wann sinnvoll |
|
||||||
|
| -------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Unternehmensanmeldung** (OIDC) | In der Firma der Normalfall. Ein Klick, die Anmeldung übernimmt Entra ID beziehungsweise authentik. |
|
||||||
|
| **Passkey** | Der bequemste Weg am eigenen Gerät: Fingerabdruck, Gesicht oder Geräte-PIN, kein Passwort. |
|
||||||
|
| **E-Mail und Passwort** | Der Rückfallweg. Oft nur daheim freigeschaltet. |
|
||||||
|
|
||||||
|
## Mit Passkey anmelden
|
||||||
|
|
||||||
|
1. E-Mail-Adresse eintragen.
|
||||||
|
2. Auf **Mit Passkey anmelden** klicken.
|
||||||
|
3. Das Gerät fragt nach Fingerabdruck, Gesicht oder PIN.
|
||||||
|
|
||||||
|
Fertig — du landest auf der Startseite.
|
||||||
|
|
||||||
|
> **Warum erst die E-Mail-Adresse?** Damit der Planer weiß, welche Passkeys er
|
||||||
|
> anbieten soll. Er antwortet dabei für unbekannte Adressen genauso wie für
|
||||||
|
> bekannte, damit sich von außen nicht abfragen lässt, wer hier ein Konto hat.
|
||||||
|
|
||||||
|
## Einen Passkey einrichten
|
||||||
|
|
||||||
|
Das geht erst, wenn du angemeldet bist — also beim ersten Mal über OIDC oder
|
||||||
|
über E-Mail und Passwort.
|
||||||
|
|
||||||
|
1. Oben rechts auf **Einstellungen**.
|
||||||
|
2. Im Abschnitt _Passkeys_ einen Namen für das Gerät eintragen, etwa
|
||||||
|
„Arbeitsrechner" oder „Diensthandy".
|
||||||
|
3. Auf **Anlegen** klicken und die Abfrage des Geräts bestätigen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Der Passkey steht danach in der Liste, mit Anlegedatum und letzter Benutzung.
|
||||||
|
|
||||||
|
> **Lege zwei Passkeys an, auf zwei verschiedenen Geräten.** Sonst sperrst du
|
||||||
|
> dich aus, sobald ein Gerät verlorengeht oder zurückgesetzt wird.
|
||||||
|
|
||||||
|
Unter jedem Eintrag steht, ob der Passkey **geräteübergreifend** ist — dann
|
||||||
|
liegt er im Schlüsselbund von Apple, Google oder deinem Passwortmanager und ist
|
||||||
|
auf deinen anderen Geräten ebenfalls verfügbar. Steht dort **nur dieses
|
||||||
|
Gerät**, ist er an genau dieses Gerät gebunden.
|
||||||
|
|
||||||
|
## Einen Passkey entfernen
|
||||||
|
|
||||||
|
In den Einstellungen auf das Papierkorb-Symbol neben dem Eintrag. Der Passkey
|
||||||
|
ist sofort ungültig; auf dem Gerät selbst bleibt ein Rest zurück, den du dort
|
||||||
|
im Schlüsselbund löschen kannst.
|
||||||
|
|
||||||
|
## Abmelden
|
||||||
|
|
||||||
|
Oben rechts auf das Symbol mit dem Pfeil aus der Tür.
|
||||||
|
|
||||||
|
## Wenn es klemmt
|
||||||
|
|
||||||
|
| Was du siehst | Was dahintersteckt |
|
||||||
|
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| „Anmeldung nicht möglich. Bitte Angaben prüfen." | Diese eine Meldung gilt für alle Fälle: falsches Passwort, unbekannte Adresse, gesperrtes Konto. Das ist Absicht — sonst ließe sich abfragen, wer hier ein Konto hat. |
|
||||||
|
| Es passiert nichts nach dem Klick auf **Mit Passkey anmelden** | Du hast den Systemdialog abgebrochen. Einfach noch einmal klicken. |
|
||||||
|
| „Dieses Gerät ist hier schon hinterlegt." | Für dieses Gerät gibt es bereits einen Passkey. Ein zweiter bringt nichts. |
|
||||||
|
| Der Passkey-Knopf tut gar nichts | Der Browser unterstützt WebAuthn nicht, oder die Seite läuft nicht über HTTPS. Passkeys brauchen eine sichere Verbindung — `localhost` ausgenommen. |
|
||||||
|
| Passkey lässt sich nicht anlegen | Meist stimmt die Konfiguration nicht: `WEBAUTHN_ORIGIN` muss exakt der Adresse im Browser entsprechen, inklusive Port. Siehe [README](../../README.md). |
|
||||||
|
|
||||||
|
## Ausgesperrt?
|
||||||
|
|
||||||
|
Wenn kein Weg mehr hineinführt, hilft nur jemand mit Zugriff auf den Server.
|
||||||
|
Dort lässt sich mit `ADMIN_EMAIL` und `ADMIN_PASSWORT` in der `.env` und einem
|
||||||
|
erneuten Seed-Lauf wieder ein Passwort setzen — siehe
|
||||||
|
[Schnellstart, Schritt 5](../SCHNELLSTART.md).
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
Unten in der Fußzeile und in den Einstellungen steht, welche Rolle du hast.
|
||||||
|
|
||||||
|
- **Benutzer** – sieht und plant die eigenen Sachen.
|
||||||
|
- **Verwaltung** – darf zusätzlich Bereiche ändern und das
|
||||||
|
[Protokoll](protokoll.md) einsehen.
|
||||||
|
|
||||||
|
Die Rolle vergibt die Verwaltung; sie lässt sich nicht selbst hochstufen.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Bereiche einrichten
|
||||||
|
|
||||||
|
Ein **Bereich** ist ein Lebensbereich: Firma, Gewerbe, Privat, Homelab. Jede
|
||||||
|
Aufgabe und jeder Zeitblock gehört später zu genau einem davon.
|
||||||
|
|
||||||
|
Der Bereich bestimmt drei Dinge:
|
||||||
|
|
||||||
|
- **die Farbe**, an der du im Kalender sofort erkennst, worum es geht,
|
||||||
|
- **das Arbeitsfenster** — die Tageszeit, in der für diesen Bereich überhaupt
|
||||||
|
geplant wird,
|
||||||
|
- **die Tageskapazität** — wie viele Minuten je Wochentag höchstens
|
||||||
|
hineingehen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> Bereiche ändern darf nur die **Verwaltung**. Als Benutzer siehst du sie,
|
||||||
|
> aber ohne die Formulare.
|
||||||
|
|
||||||
|
## Einen Bereich anlegen
|
||||||
|
|
||||||
|
1. Im Menü auf **Bereiche**.
|
||||||
|
2. Ganz unten unter _Neuen Bereich anlegen_ die Felder ausfüllen.
|
||||||
|
3. Auf **Bereich anlegen** klicken.
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| ---------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||||
|
| **Name** | Zwei bis 40 Zeichen, muss eindeutig sein |
|
||||||
|
| **Farbe** | Wird zum Farbpunkt in Listen und später im Kalender |
|
||||||
|
| **Arbeitsfenster von / bis** | Zwischen diesen Uhrzeiten wird geplant. Muss nach dem Beginn enden |
|
||||||
|
| **Tageskapazität** | Minuten je Wochentag. 0 heißt: an diesem Tag gar nicht |
|
||||||
|
| **In Benutzung** | Abgehakt lassen. Ohne Haken bleibt der Bereich erhalten, taucht aber nicht mehr zur Auswahl auf |
|
||||||
|
| **Reihenfolge** | Kleinere Zahlen stehen weiter oben |
|
||||||
|
|
||||||
|
## Was eine sinnvolle Kapazität ist
|
||||||
|
|
||||||
|
Nicht die Zeit, die der Tag hergibt — die Zeit, die **wirklich für geplante
|
||||||
|
Arbeit** übrig bleibt, nach Besprechungen, Störungen und Pausen.
|
||||||
|
|
||||||
|
Ein brauchbarer Anfang:
|
||||||
|
|
||||||
|
| Bereich | Montag bis Donnerstag | Freitag | Wochenende |
|
||||||
|
| ------- | --------------------- | ------- | --------------- |
|
||||||
|
| Firma | 360 min (6 h) | 240 min | 0 |
|
||||||
|
| Gewerbe | 90 min | 60 min | Samstag 180 min |
|
||||||
|
| Privat | 60 min | 120 min | je 240 min |
|
||||||
|
| Homelab | 0 bis 60 min | 0 | Samstag 120 min |
|
||||||
|
|
||||||
|
Wenn am Ende der Woche regelmäßig etwas liegenbleibt, ist die Kapazität zu
|
||||||
|
hoch angesetzt, nicht der Tag zu kurz.
|
||||||
|
|
||||||
|
> Die Tageskapazität darf nie länger sein als das Arbeitsfenster. Ein Fenster
|
||||||
|
> von 08:00 bis 17:00 fasst höchstens 540 Minuten; trägst du mehr ein, weist
|
||||||
|
> das Formular es ab.
|
||||||
|
|
||||||
|
## Einen Bereich ändern
|
||||||
|
|
||||||
|
Auf **Bearbeiten** unter dem Bereich klicken, ändern, **Speichern**. Mit
|
||||||
|
**Schließen** verwirfst du, was du noch nicht gespeichert hast.
|
||||||
|
|
||||||
|
## Einen Bereich loswerden
|
||||||
|
|
||||||
|
Zwei Wege, und meistens ist der erste der richtige:
|
||||||
|
|
||||||
|
1. **Stilllegen** – den Haken bei _In Benutzung_ entfernen und speichern. Alles
|
||||||
|
Bisherige bleibt erhalten, der Bereich taucht nur nicht mehr zur Auswahl auf.
|
||||||
|
2. **Löschen** – der rote Knopf. Endgültig.
|
||||||
|
|
||||||
|
> Ab M2 hängen Aufgaben an Bereichen. Dann ist Stilllegen fast immer die
|
||||||
|
> richtige Wahl, weil beim Löschen auch die Zuordnung verlorenginge.
|
||||||
|
|
||||||
|
Jede Änderung landet im [Protokoll](protokoll.md).
|
||||||
|
|
||||||
|
## Typische Fehler
|
||||||
|
|
||||||
|
| Meldung | Was zu tun ist |
|
||||||
|
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||||
|
| „Diesen Namen gibt es schon." | Namen sind eindeutig. Vielleicht existiert der Bereich stillgelegt schon. |
|
||||||
|
| „Das Arbeitsfenster muss nach seinem Beginn enden." | Von-Zeit liegt hinter der Bis-Zeit. Über Mitternacht hinweg geht nicht — dafür lieber zwei Bereiche. |
|
||||||
|
| „Keine Tageskapazität darf länger sein als das Arbeitsfenster." | Entweder die Kapazität senken oder das Fenster weiten. |
|
||||||
|
| „Dazu fehlt dir die Berechtigung." | Du bist als Benutzer angemeldet, nicht als Verwaltung. |
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Protokoll
|
||||||
|
|
||||||
|
Jede schreibende Aktion im Planer hinterlässt einen Eintrag: wer, wann, was,
|
||||||
|
und woran. Einträge lassen sich weder ändern noch löschen.
|
||||||
|
|
||||||
|
Die Seite steht nur der **Verwaltung** offen. Als Benutzer siehst du den
|
||||||
|
Menüpunkt gar nicht erst; wer die Adresse direkt aufruft, landet auf einem
|
||||||
|
Hinweis.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Was dort steht
|
||||||
|
|
||||||
|
| Spalte | Bedeutung |
|
||||||
|
| ------------- | --------------------------------------------------------------------- |
|
||||||
|
| **Zeitpunkt** | Datum und Uhrzeit in Europe/Berlin |
|
||||||
|
| **Wer** | Die E-Mail-Adresse zum Zeitpunkt der Aktion |
|
||||||
|
| **Was** | Die Aktion, in der Form `objekt.verb` |
|
||||||
|
| **Objekt** | Die Art des betroffenen Objekts, dahinter die fachlichen Einzelheiten |
|
||||||
|
|
||||||
|
Angezeigt werden die letzten 100 Einträge, die neuesten zuerst.
|
||||||
|
|
||||||
|
## Die Aktionen
|
||||||
|
|
||||||
|
| Aktion | Bedeutung |
|
||||||
|
| ------------------- | --------------------------------------------------- |
|
||||||
|
| `bereich.angelegt` | Ein neuer Bereich ist entstanden |
|
||||||
|
| `bereich.geaendert` | Name, Farbe, Fenster oder Kapazität wurden geändert |
|
||||||
|
| `bereich.geloescht` | Ein Bereich wurde entfernt |
|
||||||
|
| `passkey.angelegt` | Jemand hat ein Gerät hinterlegt |
|
||||||
|
| `passkey.entfernt` | Ein Gerät wurde entfernt |
|
||||||
|
|
||||||
|
Mit jedem Meilenstein kommen Aktionen hinzu.
|
||||||
|
|
||||||
|
## Was dort bewusst **nicht** steht
|
||||||
|
|
||||||
|
- Keine Passwörter, Hashes oder Tokens.
|
||||||
|
- Keine Termin-Betreffs und keine Mailinhalte.
|
||||||
|
- Keine Zugangsdaten der Kalenderkonten.
|
||||||
|
|
||||||
|
Das ist doppelt abgesichert: Solche Felder werden gar nicht erst übergeben,
|
||||||
|
und vor dem Speichern ersetzt der Planer zusätzlich jedes Feld, dessen Name
|
||||||
|
nach einem Geheimnis aussieht, durch `[entfernt]`. Wenn du dort `[entfernt]`
|
||||||
|
liest, hat dieser zweite Riegel gegriffen.
|
||||||
|
|
||||||
|
## Warum die E-Mail-Adresse und nicht nur die Kennung
|
||||||
|
|
||||||
|
Wird ein Benutzer gelöscht, verschwinden seine Passkeys und verknüpften
|
||||||
|
Konten — der Protokolleintrag bleibt. Die Verknüpfung zum Konto wird gelöst,
|
||||||
|
die Adresse bleibt als Nachweis stehen. Sonst wäre das Protokoll nach einer
|
||||||
|
Löschung wertlos.
|
||||||
|
|
||||||
|
## Aufbewahrung
|
||||||
|
|
||||||
|
Einträge werden nicht automatisch entfernt. Die Tabelle wächst langsam — ein
|
||||||
|
Eintrag je Änderung, nicht je Seitenaufruf. Sollte sie einmal stören, gehört
|
||||||
|
das Aufräumen in die Sicherungsroutine und nicht in die Anwendung.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rauchtest der Anmeldung.
|
||||||
|
*
|
||||||
|
* Prüft den Weg, der in M1 wirklich trägt: Wer nicht angemeldet ist, kommt
|
||||||
|
* nirgends hin; mit richtigem Passwort schon; und die Verwaltungsseite bleibt
|
||||||
|
* verschlossen, wenn die Rolle nicht reicht.
|
||||||
|
*
|
||||||
|
* Voraussetzung: "npm run db:seed" ist gelaufen und ADMIN_EMAIL sowie
|
||||||
|
* ADMIN_PASSWORT sind gesetzt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
||||||
|
const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||||
|
|
||||||
|
test.describe('Anmeldung', () => {
|
||||||
|
test.skip(
|
||||||
|
!EMAIL || !PASSWORT,
|
||||||
|
'ADMIN_EMAIL und ADMIN_PASSWORT sind nicht gesetzt – ohne sie gibt es kein Testkonto.',
|
||||||
|
)
|
||||||
|
|
||||||
|
test('leitet nicht Angemeldete zur Anmeldeseite', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page).toHaveURL(/\/anmelden/)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Planer' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('merkt sich das Ziel und kehrt nach der Anmeldung dorthin zurück', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/bereiche')
|
||||||
|
await expect(page).toHaveURL(/\/anmelden\?weiter=%2Fbereiche/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist falsche Zugangsdaten ab, ohne zu verraten warum', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||||
|
await page.getByLabel('Passwort').fill('ganz-sicher-falsch-123')
|
||||||
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('Anmeldung nicht möglich')).toBeVisible()
|
||||||
|
await expect(page).toHaveURL(/\/anmelden/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt mit richtigem Passwort hinein und wieder hinaus', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||||
|
await page.getByLabel('Passwort').fill(PASSWORT)
|
||||||
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Bereiche' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByTitle('Abmelden').click()
|
||||||
|
await expect(page).toHaveURL(/\/anmelden/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt der Verwaltung das Protokoll', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden')
|
||||||
|
await page.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||||
|
await page.getByLabel('Passwort').fill(PASSWORT)
|
||||||
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Protokoll' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('der Health-Endpunkt antwortet ohne Anmeldung', async ({ request }) => {
|
||||||
|
const antwort = await request.get('/api/health')
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
|
||||||
|
const inhalt = (await antwort.json()) as Record<string, unknown>
|
||||||
|
expect(inhalt.status).toBe('ok')
|
||||||
|
expect(inhalt.datenbank).toBe('ok')
|
||||||
|
expect(typeof inhalt.version).toBe('string')
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass die Rollentrennung serverseitig hält.
|
||||||
|
*
|
||||||
|
* Die Oberfläche blendet Knöpfe aus — darauf allein darf man sich nicht
|
||||||
|
* verlassen. Deshalb ruft dieser Test die Verwaltungsseite direkt auf, statt
|
||||||
|
* nach einem Menüpunkt zu suchen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMAIL = 'zzz-e2e-benutzer@example.invalid'
|
||||||
|
const PASSWORT = 'nur-fuer-den-test-1234'
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL ?? ''
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||||
|
|
||||||
|
test.describe('Rollentrennung', () => {
|
||||||
|
test.skip(!verbindung, 'DATABASE_URL ist nicht gesetzt.')
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await db.benutzer.deleteMany({ where: { email: EMAIL } })
|
||||||
|
await db.benutzer.create({
|
||||||
|
data: {
|
||||||
|
email: EMAIL,
|
||||||
|
name: 'Testbenutzer ohne Rechte',
|
||||||
|
rolle: 'BENUTZER',
|
||||||
|
passwortHash: await bcrypt.hash(PASSWORT, 10),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.auditEintrag.deleteMany({ where: { benutzerEmail: EMAIL } })
|
||||||
|
await db.benutzer.deleteMany({ where: { email: EMAIL } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function anmelden(seite: Page): Promise<void> {
|
||||||
|
await seite.goto('/anmelden')
|
||||||
|
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||||
|
await seite.getByLabel('Passwort').fill(PASSWORT)
|
||||||
|
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
await expect(seite.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('zeigt einem Benutzer den Menüpunkt Protokoll gar nicht', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await expect(page.getByRole('link', { name: 'Protokoll' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Benutzer auch beim direkten Aufruf nicht ins Protokoll', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/verwaltung/protokoll')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/keine-berechtigung/)
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /Dazu fehlt dir die Berechtigung/ }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('zeigt einem Benutzer die Bereiche, aber ohne Formulare', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
await page.goto('/bereiche')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Bereiche' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: 'Bearbeiten' })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('button', { name: 'Bereich anlegen' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import naechstesWebVitals from 'eslint-config-next/core-web-vitals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
const wurzel = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'.next/**',
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'playwright-report/**',
|
||||||
|
'test-results/**',
|
||||||
|
// Vom Generator erzeugt, nicht von Hand gepflegt.
|
||||||
|
'src/generated/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...naechstesWebVitals,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: wurzel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-floating-promises': 'error',
|
||||||
|
'@typescript-eslint/require-await': 'error',
|
||||||
|
// Keine Geheimnisse in Logs: console nur fuer Warnungen und Fehler.
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Skripte, Seed und Tests duerfen auf die Konsole schreiben.
|
||||||
|
{
|
||||||
|
files: ['scripts/**', 'prisma/**', '**/*.test.ts', '**/*.test.tsx', 'e2e/**'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.js', '**/*.mjs', '**/*.cjs'],
|
||||||
|
extends: [tseslint.configs.disableTypeChecked],
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const config: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
reactStrictMode: true,
|
||||||
|
poweredByHeader: false,
|
||||||
|
typedRoutes: true,
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '2mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"name": "planer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start",
|
||||||
|
"pruefen": "node scripts/pruefen.mjs",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:pruefen": "prettier --check .",
|
||||||
|
"lint": "eslint . --max-warnings 0",
|
||||||
|
"typen": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:beobachten": "vitest",
|
||||||
|
"test:integration": "tsx scripts/integrationstest.ts",
|
||||||
|
"test:migration": "tsx scripts/migrationstest.ts",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
|
"db:generieren": "prisma generate",
|
||||||
|
"db:migrieren": "prisma migrate dev",
|
||||||
|
"db:einspielen": "prisma migrate deploy",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"db:demo-seed": "tsx prisma/demo-seed.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@simplewebauthn/browser": "14.0.0",
|
||||||
|
"@simplewebauthn/server": "14.0.1",
|
||||||
|
"nodemailer": "10.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
|
"@date-fns/tz": "1.5.0",
|
||||||
|
"@prisma/adapter-pg": "7.10.0",
|
||||||
|
"@prisma/client": "7.10.0",
|
||||||
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
|
"@radix-ui/react-label": "2.1.15",
|
||||||
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
|
"@simplewebauthn/browser": "14.0.0",
|
||||||
|
"@simplewebauthn/server": "14.0.1",
|
||||||
|
"bcryptjs": "3.0.3",
|
||||||
|
"class-variance-authority": "0.7.1",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"date-fns": "4.4.0",
|
||||||
|
"lucide-react": "1.44.0",
|
||||||
|
"next": "16.3.4",
|
||||||
|
"next-auth": "5.0.0-beta.32",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
|
"node-cron": "4.6.0",
|
||||||
|
"nodemailer": "10.0.3",
|
||||||
|
"pg": "8.23.0",
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0",
|
||||||
|
"server-only": "0.0.1",
|
||||||
|
"sonner": "2.0.8",
|
||||||
|
"tailwind-merge": "3.6.0",
|
||||||
|
"zod": "4.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
|
"@testing-library/react": "16.3.3",
|
||||||
|
"@types/node": "24.13.4",
|
||||||
|
"@types/nodemailer": "8.0.1",
|
||||||
|
"@types/pg": "8.23.1",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
|
"dotenv": "17.4.2",
|
||||||
|
"eslint": "9.39.5",
|
||||||
|
"eslint-config-next": "16.3.4",
|
||||||
|
"jsdom": "30.0.1",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"prettier-plugin-tailwindcss": "0.8.1",
|
||||||
|
"prisma": "7.10.0",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"tsx": "4.23.13",
|
||||||
|
"tw-animate-css": "1.4.0",
|
||||||
|
"typescript": "6.0.3",
|
||||||
|
"typescript-eslint": "8.70.0",
|
||||||
|
"vitest": "5.0.0"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"prisma@7.10.0": true,
|
||||||
|
"@prisma/engines@7.10.0": true,
|
||||||
|
"esbuild@0.28.2": true,
|
||||||
|
"unrs-resolver@1.12.2": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
||||||
|
const BASIS = `http://127.0.0.1:${PORT}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E-Tests gegen eine echte, gebaute Anwendung.
|
||||||
|
*
|
||||||
|
* Der Testserver läuft auf einem eigenen Port, damit er einer laufenden
|
||||||
|
* Entwicklungsinstanz nicht in die Quere kommt. Die Datenbank ist dieselbe wie
|
||||||
|
* im Entwicklungsbetrieb – die Tests legen nur Daten mit eigenem Präfix an und
|
||||||
|
* räumen hinter sich auf.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: false,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
|
timeout: 30_000,
|
||||||
|
|
||||||
|
use: {
|
||||||
|
baseURL: BASIS,
|
||||||
|
locale: 'de-DE',
|
||||||
|
timezoneId: 'Europe/Berlin',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run start',
|
||||||
|
url: `${BASIS}/api/health`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
env: {
|
||||||
|
PORT: String(PORT),
|
||||||
|
APP_URL: BASIS,
|
||||||
|
WEBAUTHN_ORIGIN: BASIS,
|
||||||
|
WEBAUTHN_RP_ID: '127.0.0.1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
const PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Konfiguration für die Screenshot-Instanz. Sie startet keinen Server —
|
||||||
|
* darum kümmert sich scripts/screenshots.ts über Docker Compose.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './screenshots',
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: 'list',
|
||||||
|
timeout: 60_000,
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${PORT}`,
|
||||||
|
locale: 'de-DE',
|
||||||
|
timezoneId: 'Europe/Berlin',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import { defineConfig } from 'prisma/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: 'prisma/schema.prisma',
|
||||||
|
migrations: {
|
||||||
|
path: 'prisma/migrations',
|
||||||
|
seed: 'tsx prisma/seed.ts',
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
// Bewusst über process.env statt über den env()-Helfer: "prisma generate"
|
||||||
|
// läuft auch beim Bauen des Abbilds, wo es noch gar keine Datenbank gibt.
|
||||||
|
// env() bricht dort ab. Fehlt die URL für migrate oder studio, meldet
|
||||||
|
// Prisma das selbst.
|
||||||
|
url: process.env.DATABASE_URL ?? '',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Daten für die Screenshot-Instanz.
|
||||||
|
*
|
||||||
|
* Alles hier ist frei erfunden. Diese Datei darf niemals gegen eine
|
||||||
|
* Datenbank mit echten Daten laufen — deshalb bricht sie ab, wenn
|
||||||
|
* DEMO_MODUS nicht ausdrücklich auf true steht.
|
||||||
|
*
|
||||||
|
* Aufruf: über npm run screenshots, nicht von Hand.
|
||||||
|
*/
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
|
||||||
|
if (process.env.DEMO_MODUS !== 'true') {
|
||||||
|
console.error(
|
||||||
|
'Der Demo-Seed läuft nur mit DEMO_MODUS=true. Das ist Absicht: er würde ' +
|
||||||
|
'sonst erfundene Daten in eine echte Datenbank schreiben.',
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL
|
||||||
|
if (!verbindung) {
|
||||||
|
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||||
|
|
||||||
|
export const DEMO_EMAIL = 'martina.beispiel@planer.invalid'
|
||||||
|
export const DEMO_PASSWORT = 'demo-instanz-nur-fuer-bilder'
|
||||||
|
|
||||||
|
const BEREICHE = [
|
||||||
|
{
|
||||||
|
name: 'Firma',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 1,
|
||||||
|
arbeitsfensterVon: 450,
|
||||||
|
arbeitsfensterBis: 990,
|
||||||
|
kapazitaetMo: 360,
|
||||||
|
kapazitaetDi: 360,
|
||||||
|
kapazitaetMi: 360,
|
||||||
|
kapazitaetDo: 360,
|
||||||
|
kapazitaetFr: 240,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#7c3aed',
|
||||||
|
reihenfolge: 2,
|
||||||
|
arbeitsfensterVon: 1020,
|
||||||
|
arbeitsfensterBis: 1260,
|
||||||
|
kapazitaetMo: 90,
|
||||||
|
kapazitaetDi: 90,
|
||||||
|
kapazitaetMi: 90,
|
||||||
|
kapazitaetDo: 90,
|
||||||
|
kapazitaetFr: 60,
|
||||||
|
kapazitaetSa: 180,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Privat',
|
||||||
|
farbe: '#16a34a',
|
||||||
|
reihenfolge: 3,
|
||||||
|
arbeitsfensterVon: 1020,
|
||||||
|
arbeitsfensterBis: 1320,
|
||||||
|
kapazitaetMo: 60,
|
||||||
|
kapazitaetDi: 60,
|
||||||
|
kapazitaetMi: 60,
|
||||||
|
kapazitaetDo: 60,
|
||||||
|
kapazitaetFr: 120,
|
||||||
|
kapazitaetSa: 240,
|
||||||
|
kapazitaetSo: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Homelab',
|
||||||
|
farbe: '#ea580c',
|
||||||
|
reihenfolge: 4,
|
||||||
|
arbeitsfensterVon: 1140,
|
||||||
|
arbeitsfensterBis: 1380,
|
||||||
|
kapazitaetMo: 0,
|
||||||
|
kapazitaetDi: 60,
|
||||||
|
kapazitaetMi: 0,
|
||||||
|
kapazitaetDo: 60,
|
||||||
|
kapazitaetFr: 0,
|
||||||
|
kapazitaetSa: 120,
|
||||||
|
kapazitaetSo: 60,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Erfundene Protokolleinträge, damit die Protokollseite nicht leer wirkt. */
|
||||||
|
const PROTOKOLL = [
|
||||||
|
{ aktion: 'bereich.angelegt', objektTyp: 'Bereich', details: { name: 'Homelab' } },
|
||||||
|
{
|
||||||
|
aktion: 'bereich.geaendert',
|
||||||
|
objektTyp: 'Bereich',
|
||||||
|
details: { vorher: 'Nebenerwerb', nachher: 'Gewerbe' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aktion: 'passkey.angelegt',
|
||||||
|
objektTyp: 'Passkey',
|
||||||
|
details: { bezeichnung: 'Diensthandy' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('Demo-Daten einspielen.')
|
||||||
|
|
||||||
|
for (const bereich of BEREICHE) {
|
||||||
|
await db.bereich.upsert({
|
||||||
|
where: { name: bereich.name },
|
||||||
|
update: bereich,
|
||||||
|
create: bereich,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const benutzer = await db.benutzer.upsert({
|
||||||
|
where: { email: DEMO_EMAIL },
|
||||||
|
update: { rolle: 'ADMIN', name: 'Martina Beispiel' },
|
||||||
|
create: {
|
||||||
|
email: DEMO_EMAIL,
|
||||||
|
name: 'Martina Beispiel',
|
||||||
|
rolle: 'ADMIN',
|
||||||
|
passwortHash: await bcrypt.hash(DEMO_PASSWORT, 10),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.auditEintrag.deleteMany({})
|
||||||
|
// Feste Zeitpunkte, damit die Bilder zwischen zwei Läufen gleich bleiben.
|
||||||
|
let versatz = 0
|
||||||
|
for (const eintrag of PROTOKOLL) {
|
||||||
|
versatz += 37
|
||||||
|
await db.auditEintrag.create({
|
||||||
|
data: {
|
||||||
|
zeitpunkt: new Date(Date.UTC(2026, 2, 17, 9, versatz % 60)),
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
benutzerEmail: benutzer.email,
|
||||||
|
aktion: eintrag.aktion,
|
||||||
|
objektTyp: eintrag.objektTyp,
|
||||||
|
details: eintrag.details,
|
||||||
|
instanz: 'daheim',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.passkey.deleteMany({ where: { benutzerId: benutzer.id } })
|
||||||
|
await db.passkey.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
credentialId: 'demo-passkey-diensthandy',
|
||||||
|
publicKey: Buffer.from([0]),
|
||||||
|
bezeichnung: 'Diensthandy',
|
||||||
|
gesichert: true,
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 15)),
|
||||||
|
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 7, 42)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
.catch(async (fehler: unknown) => {
|
||||||
|
console.error(fehler)
|
||||||
|
await db.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Rolle" AS ENUM ('ADMIN', 'BENUTZER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "benutzer" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"passwortHash" TEXT,
|
||||||
|
"rolle" "Rolle" NOT NULL DEFAULT 'BENUTZER',
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"bildUrl" TEXT,
|
||||||
|
"emailGeprueft" TIMESTAMP(3),
|
||||||
|
"zuletztAktiv" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "benutzer_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "konto" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "konto_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "passkey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"credentialId" TEXT NOT NULL,
|
||||||
|
"publicKey" BYTEA NOT NULL,
|
||||||
|
"counter" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"transports" TEXT,
|
||||||
|
"geraetetyp" TEXT,
|
||||||
|
"gesichert" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"bezeichnung" TEXT NOT NULL,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"zuletztBenutzt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "passkey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "webauthn_challenge" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"kennung" TEXT NOT NULL,
|
||||||
|
"challenge" TEXT NOT NULL,
|
||||||
|
"zweck" TEXT NOT NULL,
|
||||||
|
"gueltigBis" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "webauthn_challenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "bereich" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"farbe" TEXT NOT NULL,
|
||||||
|
"reihenfolge" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"kapazitaetMo" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetDi" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetMi" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetDo" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetFr" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetSa" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"kapazitaetSo" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"arbeitsfensterVon" INTEGER NOT NULL DEFAULT 480,
|
||||||
|
"arbeitsfensterBis" INTEGER NOT NULL DEFAULT 1020,
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"aktualisiertAm" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "bereich_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_eintrag" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"zeitpunkt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"benutzerId" TEXT,
|
||||||
|
"benutzerEmail" TEXT NOT NULL,
|
||||||
|
"aktion" TEXT NOT NULL,
|
||||||
|
"objektTyp" TEXT NOT NULL,
|
||||||
|
"objektId" TEXT,
|
||||||
|
"details" JSONB,
|
||||||
|
"instanz" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "audit_eintrag_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "benutzer_email_key" ON "benutzer"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "benutzer_aktiv_idx" ON "benutzer"("aktiv");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "konto_benutzerId_idx" ON "konto"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "konto_provider_providerAccountId_key" ON "konto"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "passkey_benutzerId_idx" ON "passkey"("benutzerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "webauthn_challenge_kennung_zweck_idx" ON "webauthn_challenge"("kennung", "zweck");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "bereich_name_key" ON "bereich"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "bereich_aktiv_reihenfolge_idx" ON "bereich"("aktiv", "reihenfolge");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_eintrag_zeitpunkt_idx" ON "audit_eintrag"("zeitpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_eintrag_objektTyp_objektId_idx" ON "audit_eintrag"("objektTyp", "objektId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_eintrag_benutzerId_idx" ON "audit_eintrag"("benutzerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "konto" ADD CONSTRAINT "konto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "audit_eintrag" ADD CONSTRAINT "audit_eintrag_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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,148 @@
|
|||||||
|
// Datenmodell des Planers.
|
||||||
|
// Fachbegriffe auf Deutsch, technische Felder auf Englisch.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
moduleFormat = "esm"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Rolle {
|
||||||
|
ADMIN
|
||||||
|
BENUTZER
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Mensch, der sich anmelden darf.
|
||||||
|
model Benutzer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
/// Nur gesetzt, wenn die Anmeldung per Passwort erlaubt ist.
|
||||||
|
passwortHash String?
|
||||||
|
rolle Rolle @default(BENUTZER)
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
bildUrl String?
|
||||||
|
emailGeprueft DateTime?
|
||||||
|
zuletztAktiv DateTime?
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
konten Konto[]
|
||||||
|
passkeys Passkey[]
|
||||||
|
audit AuditEintrag[]
|
||||||
|
|
||||||
|
@@index([aktiv])
|
||||||
|
@@map("benutzer")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verknuepfung zu einem OIDC-Anbieter (Entra ID, authentik).
|
||||||
|
model Konto {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String?
|
||||||
|
access_token String?
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String?
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("konto")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein registrierter Passkey (WebAuthn) eines Benutzers.
|
||||||
|
model Passkey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
/// Base64URL der Credential-ID.
|
||||||
|
credentialId String @unique
|
||||||
|
publicKey Bytes
|
||||||
|
counter BigInt @default(0)
|
||||||
|
/// Kommagetrennt, z. B. "internal,hybrid".
|
||||||
|
transports String?
|
||||||
|
geraetetyp String?
|
||||||
|
gesichert Boolean @default(false)
|
||||||
|
bezeichnung String
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
zuletztBenutzt DateTime?
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("passkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Laufende WebAuthn-Herausforderung; kurzlebig, wird nach Gebrauch geloescht.
|
||||||
|
model WebauthnChallenge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// E-Mail bei der Anmeldung, Benutzer-ID bei der Registrierung.
|
||||||
|
kennung String
|
||||||
|
challenge String
|
||||||
|
zweck String
|
||||||
|
gueltigBis DateTime
|
||||||
|
|
||||||
|
@@index([kennung, zweck])
|
||||||
|
@@map("webauthn_challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lebensbereich mit Farbe, Tageskapazitaet und Arbeitsfenster.
|
||||||
|
model Bereich {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
/// Hex-Farbe inklusive Raute, z. B. "#2563eb".
|
||||||
|
farbe String
|
||||||
|
reihenfolge Int @default(0)
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
|
/// Tageskapazitaet in Minuten, Montag bis Sonntag.
|
||||||
|
kapazitaetMo Int @default(0)
|
||||||
|
kapazitaetDi Int @default(0)
|
||||||
|
kapazitaetMi Int @default(0)
|
||||||
|
kapazitaetDo Int @default(0)
|
||||||
|
kapazitaetFr Int @default(0)
|
||||||
|
kapazitaetSa Int @default(0)
|
||||||
|
kapazitaetSo Int @default(0)
|
||||||
|
|
||||||
|
/// Arbeitsfenster als Minuten seit Mitternacht, Europe/Berlin.
|
||||||
|
arbeitsfensterVon Int @default(480)
|
||||||
|
arbeitsfensterBis Int @default(1020)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([aktiv, reihenfolge])
|
||||||
|
@@map("bereich")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unveraenderlicher Nachweis jeder schreibenden Aktion.
|
||||||
|
model AuditEintrag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
zeitpunkt DateTime @default(now())
|
||||||
|
benutzerId String?
|
||||||
|
/// Bleibt erhalten, auch wenn der Benutzer spaeter geloescht wird.
|
||||||
|
benutzerEmail String
|
||||||
|
aktion String
|
||||||
|
objektTyp String
|
||||||
|
objektId String?
|
||||||
|
/// Nur fachliche Felder, niemals Tokens, Mailinhalte oder Termin-Betreffs.
|
||||||
|
details Json?
|
||||||
|
instanz String
|
||||||
|
|
||||||
|
benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([zeitpunkt])
|
||||||
|
@@index([objektTyp, objektId])
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("audit_eintrag")
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Grundausstattung für eine frische Datenbank.
|
||||||
|
*
|
||||||
|
* Läuft mehrfach ohne Schaden: alles wird per upsert angelegt, vorhandene
|
||||||
|
* Einträge bleiben unangetastet. Es entstehen keine erfundenen Aufgaben oder
|
||||||
|
* Termine – dafür gibt es prisma/demo-seed.ts.
|
||||||
|
*/
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL
|
||||||
|
if (!verbindung) {
|
||||||
|
throw new Error('DATABASE_URL ist nicht gesetzt.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||||
|
|
||||||
|
const BEREICHE = [
|
||||||
|
{
|
||||||
|
name: 'Firma',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 1,
|
||||||
|
arbeitsfensterVon: 7 * 60 + 30,
|
||||||
|
arbeitsfensterBis: 16 * 60 + 30,
|
||||||
|
kapazitaetMo: 360,
|
||||||
|
kapazitaetDi: 360,
|
||||||
|
kapazitaetMi: 360,
|
||||||
|
kapazitaetDo: 360,
|
||||||
|
kapazitaetFr: 240,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#7c3aed',
|
||||||
|
reihenfolge: 2,
|
||||||
|
arbeitsfensterVon: 17 * 60,
|
||||||
|
arbeitsfensterBis: 21 * 60,
|
||||||
|
kapazitaetMo: 90,
|
||||||
|
kapazitaetDi: 90,
|
||||||
|
kapazitaetMi: 90,
|
||||||
|
kapazitaetDo: 90,
|
||||||
|
kapazitaetFr: 60,
|
||||||
|
kapazitaetSa: 180,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Privat',
|
||||||
|
farbe: '#16a34a',
|
||||||
|
reihenfolge: 3,
|
||||||
|
arbeitsfensterVon: 17 * 60,
|
||||||
|
arbeitsfensterBis: 22 * 60,
|
||||||
|
kapazitaetMo: 60,
|
||||||
|
kapazitaetDi: 60,
|
||||||
|
kapazitaetMi: 60,
|
||||||
|
kapazitaetDo: 60,
|
||||||
|
kapazitaetFr: 120,
|
||||||
|
kapazitaetSa: 240,
|
||||||
|
kapazitaetSo: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Homelab',
|
||||||
|
farbe: '#ea580c',
|
||||||
|
reihenfolge: 4,
|
||||||
|
arbeitsfensterVon: 19 * 60,
|
||||||
|
arbeitsfensterBis: 23 * 60,
|
||||||
|
kapazitaetMo: 0,
|
||||||
|
kapazitaetDi: 60,
|
||||||
|
kapazitaetMi: 0,
|
||||||
|
kapazitaetDo: 60,
|
||||||
|
kapazitaetFr: 0,
|
||||||
|
kapazitaetSa: 120,
|
||||||
|
kapazitaetSo: 60,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function bereicheAnlegen(): Promise<void> {
|
||||||
|
for (const bereich of BEREICHE) {
|
||||||
|
await db.bereich.upsert({
|
||||||
|
where: { name: bereich.name },
|
||||||
|
update: {},
|
||||||
|
create: bereich,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(`Bereiche geprüft: ${BEREICHE.map((b) => b.name).join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verwaltungAnlegen(): Promise<void> {
|
||||||
|
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase()
|
||||||
|
const passwort = process.env.ADMIN_PASSWORT
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.log(
|
||||||
|
'ADMIN_EMAIL ist nicht gesetzt – kein Verwaltungskonto angelegt. ' +
|
||||||
|
'Bei reiner OIDC-Anmeldung ist das richtig: die erste Person, die sich ' +
|
||||||
|
'anmeldet, muss danach von Hand hochgestuft werden.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await db.benutzer.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: { id: true, rolle: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (vorhanden) {
|
||||||
|
if (vorhanden.rolle !== 'ADMIN') {
|
||||||
|
await db.benutzer.update({ where: { id: vorhanden.id }, data: { rolle: 'ADMIN' } })
|
||||||
|
console.log(`${email} zur Verwaltung hochgestuft.`)
|
||||||
|
} else {
|
||||||
|
console.log(`${email} ist bereits Verwaltung.`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwort) {
|
||||||
|
await db.benutzer.create({
|
||||||
|
data: { email, rolle: 'ADMIN', name: null },
|
||||||
|
})
|
||||||
|
console.log(
|
||||||
|
`${email} als Verwaltung angelegt, ohne Passwort. ` +
|
||||||
|
'Anmeldung ist damit nur über OIDC oder einen Passkey möglich.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwort.length < 12) {
|
||||||
|
throw new Error('ADMIN_PASSWORT ist zu kurz, mindestens 12 Zeichen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.benutzer.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
rolle: 'ADMIN',
|
||||||
|
passwortHash: await bcrypt.hash(passwort, 12),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(`${email} als Verwaltung mit Passwort angelegt.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await bereicheAnlegen()
|
||||||
|
await verwaltungAnlegen()
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
.catch(async (fehler: unknown) => {
|
||||||
|
console.error(fehler)
|
||||||
|
await db.$disconnect()
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises'
|
||||||
|
import { test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt die Bilder für das Handbuch auf.
|
||||||
|
*
|
||||||
|
* Je Seite vier Bilder: hell und dunkel, Schreibtisch und Handy. Die Uhr steht
|
||||||
|
* auf einem festen Zeitpunkt, damit sich zwischen zwei Läufen nur das ändert,
|
||||||
|
* was sich wirklich geändert hat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ZIEL = 'docs/bilder'
|
||||||
|
const SCHREIBTISCH = { width: 1440, height: 900 }
|
||||||
|
const HANDY = { width: 390, height: 844 }
|
||||||
|
|
||||||
|
// Dienstag, 17. März 2026, 09:20 Uhr in Berlin.
|
||||||
|
const FESTE_ZEIT = new Date('2026-03-17T08:20:00.000Z')
|
||||||
|
|
||||||
|
const EMAIL = 'martina.beispiel@planer.invalid'
|
||||||
|
const PASSWORT = 'demo-instanz-nur-fuer-bilder'
|
||||||
|
|
||||||
|
type Aufnahme = {
|
||||||
|
name: string
|
||||||
|
pfad: string
|
||||||
|
/** Wird vor der Aufnahme ausgeführt, z. B. um ein Formular aufzuklappen. */
|
||||||
|
vorbereiten?: (seite: Page) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUFNAHMEN: Aufnahme[] = [
|
||||||
|
{ name: 'anmelden', pfad: '/anmelden' },
|
||||||
|
{ name: 'start', pfad: '/' },
|
||||||
|
{ name: 'bereiche', pfad: '/bereiche' },
|
||||||
|
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||||
|
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function anmelden(seite: Page): Promise<void> {
|
||||||
|
await seite.goto('/anmelden')
|
||||||
|
await seite.getByLabel('E-Mail-Adresse').fill(EMAIL)
|
||||||
|
await seite.getByLabel('Passwort').fill(PASSWORT)
|
||||||
|
await seite.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
await seite.waitForURL('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await mkdir(ZIEL, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const schema of ['hell', 'dunkel'] as const) {
|
||||||
|
for (const [geraet, groesse] of [
|
||||||
|
['schreibtisch', SCHREIBTISCH],
|
||||||
|
['handy', HANDY],
|
||||||
|
] as const) {
|
||||||
|
test(`Bilder: ${schema}, ${geraet}`, async ({ browser }) => {
|
||||||
|
const kontext = await browser.newContext({
|
||||||
|
viewport: groesse,
|
||||||
|
colorScheme: schema === 'dunkel' ? 'dark' : 'light',
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
})
|
||||||
|
const seite = await kontext.newPage()
|
||||||
|
await seite.clock.setFixedTime(FESTE_ZEIT)
|
||||||
|
|
||||||
|
for (const aufnahme of AUFNAHMEN) {
|
||||||
|
if (aufnahme.pfad !== '/anmelden') {
|
||||||
|
// Für alles außer der Anmeldeseite braucht es eine Sitzung.
|
||||||
|
if (
|
||||||
|
!(await seite.context().cookies()).some((k) => k.name.includes('session'))
|
||||||
|
) {
|
||||||
|
await anmelden(seite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await seite.goto(aufnahme.pfad)
|
||||||
|
await seite.waitForLoadState('networkidle')
|
||||||
|
await aufnahme.vorbereiten?.(seite)
|
||||||
|
// Der Farbschema-Umschalter und Schriften sollen fertig sein.
|
||||||
|
await seite.waitForTimeout(300)
|
||||||
|
|
||||||
|
await seite.screenshot({
|
||||||
|
path: `${ZIEL}/${aufnahme.name}-${schema}-${geraet}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await kontext.close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Integrationstest gegen eine echte Datenbank.
|
||||||
|
*
|
||||||
|
* Prüft, was Vitest nicht abdeckt: Eindeutigkeit, Fremdschlüssel, Kaskaden
|
||||||
|
* und dass das Audit-Protokoll den Benutzer überlebt.
|
||||||
|
*
|
||||||
|
* Bewusst ohne umschließende Transaktion: Der Test prüft absichtlich Fälle,
|
||||||
|
* die scheitern müssen, und ein gescheiterter Befehl bricht in PostgreSQL die
|
||||||
|
* ganze Transaktion ab. Stattdessen tragen alle Testdaten dieselbe Kennung
|
||||||
|
* und werden am Ende wieder entfernt — auch wenn der Test unterwegs abbricht.
|
||||||
|
*
|
||||||
|
* Aufruf: npm run test:integration
|
||||||
|
*/
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL
|
||||||
|
if (!verbindung) {
|
||||||
|
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: verbindung }) })
|
||||||
|
|
||||||
|
type Pruefung = { name: string; ok: boolean; hinweis?: string }
|
||||||
|
const ergebnisse: Pruefung[] = []
|
||||||
|
|
||||||
|
function pruefe(name: string, bedingung: boolean, hinweis?: string): void {
|
||||||
|
ergebnisse.push(hinweis ? { name, ok: bedingung, hinweis } : { name, ok: bedingung })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erwartet, dass die Aktion scheitert. */
|
||||||
|
async function scheitert(name: string, tun: () => Promise<unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await tun()
|
||||||
|
pruefe(name, false, 'Die Aktion war erlaubt, obwohl sie scheitern sollte.')
|
||||||
|
} catch {
|
||||||
|
pruefe(name, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kennung = `zzz-test-${Date.now()}`
|
||||||
|
const testEmail = `${kennung}@example.invalid`
|
||||||
|
|
||||||
|
async function aufraeumen(): Promise<void> {
|
||||||
|
// Reihenfolge egal, die Kaskaden erledigen den Rest; der Audit-Eintrag
|
||||||
|
// hängt bewusst an keinem Benutzer mehr.
|
||||||
|
await db.auditEintrag.deleteMany({ where: { benutzerEmail: testEmail } })
|
||||||
|
await db.benutzer.deleteMany({ where: { email: testEmail } })
|
||||||
|
await db.bereich.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
|
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// --- Benutzer und Eindeutigkeit der Adresse ------------------------------
|
||||||
|
const benutzer = await db.benutzer.create({
|
||||||
|
data: { email: testEmail, name: 'Testkonto' },
|
||||||
|
})
|
||||||
|
pruefe('Benutzer anlegen', benutzer.id.length > 0)
|
||||||
|
pruefe('Rolle ist im Zweifel BENUTZER', benutzer.rolle === 'BENUTZER')
|
||||||
|
pruefe('Konto ist im Zweifel aktiv', benutzer.aktiv === true)
|
||||||
|
|
||||||
|
await scheitert('E-Mail-Adresse ist eindeutig', () =>
|
||||||
|
db.benutzer.create({ data: { email: testEmail } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Bereich und Eindeutigkeit des Namens --------------------------------
|
||||||
|
const bereich = await db.bereich.create({
|
||||||
|
data: { name: `${kennung}-bereich`, farbe: '#2563eb', kapazitaetMo: 120 },
|
||||||
|
})
|
||||||
|
pruefe('Bereich anlegen', bereich.name.endsWith('-bereich'))
|
||||||
|
pruefe(
|
||||||
|
'Arbeitsfenster hat brauchbare Standardwerte',
|
||||||
|
bereich.arbeitsfensterVon === 480 && bereich.arbeitsfensterBis === 1020,
|
||||||
|
)
|
||||||
|
|
||||||
|
await scheitert('Bereichsname ist eindeutig', () =>
|
||||||
|
db.bereich.create({ data: { name: bereich.name, farbe: '#000000' } }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Passkey, Eindeutigkeit und Fremdschlüssel ---------------------------
|
||||||
|
const passkey = await db.passkey.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
credentialId: `${kennung}-cred`,
|
||||||
|
publicKey: Buffer.from([1, 2, 3]),
|
||||||
|
bezeichnung: 'Testgerät',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe('Passkey anlegen', passkey.counter === 0n)
|
||||||
|
pruefe(
|
||||||
|
'Zähler ist ein BigInt und beginnt bei null',
|
||||||
|
typeof passkey.counter === 'bigint',
|
||||||
|
)
|
||||||
|
|
||||||
|
await scheitert('Credential-ID ist eindeutig', () =>
|
||||||
|
db.passkey.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
credentialId: passkey.credentialId,
|
||||||
|
publicKey: Buffer.from([4]),
|
||||||
|
bezeichnung: 'Zweitgerät',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await scheitert('Passkey ohne gültigen Benutzer ist nicht möglich', () =>
|
||||||
|
db.passkey.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: 'gibt-es-nicht',
|
||||||
|
credentialId: `${kennung}-waise`,
|
||||||
|
publicKey: Buffer.from([9]),
|
||||||
|
bezeichnung: 'Waise',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Öffentlicher Schlüssel kommt unverändert zurück ---------------------
|
||||||
|
const gelesen = await db.passkey.findUnique({ where: { id: passkey.id } })
|
||||||
|
pruefe(
|
||||||
|
'Der öffentliche Schlüssel kommt Byte für Byte zurück',
|
||||||
|
Buffer.from(gelesen?.publicKey ?? []).equals(Buffer.from([1, 2, 3])),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Konto und Eindeutigkeit je Anbieter ---------------------------------
|
||||||
|
await db.konto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
type: 'oidc',
|
||||||
|
provider: 'oidc',
|
||||||
|
providerAccountId: kennung,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await scheitert('Ein Anbieterkonto ist nur einmal verknüpfbar', () =>
|
||||||
|
db.konto.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
type: 'oidc',
|
||||||
|
provider: 'oidc',
|
||||||
|
providerAccountId: kennung,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Audit überlebt den Benutzer ----------------------------------------
|
||||||
|
const audit = await db.auditEintrag.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
benutzerEmail: benutzer.email,
|
||||||
|
aktion: 'test.durchgefuehrt',
|
||||||
|
objektTyp: 'Bereich',
|
||||||
|
objektId: bereich.id,
|
||||||
|
details: { name: bereich.name },
|
||||||
|
instanz: 'daheim',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe('Audit-Eintrag anlegen', audit.id.length > 0)
|
||||||
|
|
||||||
|
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||||
|
|
||||||
|
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||||
|
pruefe('Audit-Eintrag überlebt das Löschen des Benutzers', nachher !== null)
|
||||||
|
pruefe(
|
||||||
|
'Die Kennung wird gelöst, die Adresse bleibt als Nachweis stehen',
|
||||||
|
nachher?.benutzerId === null && nachher?.benutzerEmail === testEmail,
|
||||||
|
)
|
||||||
|
|
||||||
|
pruefe(
|
||||||
|
'Passkeys verschwinden mit dem Benutzer',
|
||||||
|
(await db.passkey.count({ where: { credentialId: passkey.credentialId } })) === 0,
|
||||||
|
)
|
||||||
|
pruefe(
|
||||||
|
'Verknüpfte Konten verschwinden mit dem Benutzer',
|
||||||
|
(await db.konto.count({ where: { providerAccountId: kennung } })) === 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let abbruch: unknown = null
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((fehler: unknown) => {
|
||||||
|
abbruch = fehler
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await aufraeumen()
|
||||||
|
|
||||||
|
const uebrig =
|
||||||
|
(await db.benutzer.count({ where: { email: testEmail } })) +
|
||||||
|
(await db.bereich.count({ where: { name: { startsWith: kennung } } }))
|
||||||
|
pruefe('Aufräumen hat nichts übrig gelassen', uebrig === 0)
|
||||||
|
|
||||||
|
await db.$disconnect()
|
||||||
|
|
||||||
|
if (abbruch) {
|
||||||
|
console.error('Der Integrationstest ist abgebrochen:', abbruch)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gefallen = ergebnisse.filter((e) => !e.ok)
|
||||||
|
for (const e of ergebnisse) {
|
||||||
|
console.log(`${e.ok ? '✓' : '✗'} ${e.name}${e.hinweis ? ` – ${e.hinweis}` : ''}`)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`\n${ergebnisse.length - gefallen.length} von ${ergebnisse.length} Prüfungen bestanden.`,
|
||||||
|
)
|
||||||
|
process.exit(gefallen.length === 0 ? 0 : 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Migrationstest.
|
||||||
|
*
|
||||||
|
* Legt eine Wegwerf-Datenbank an, spielt alle Migrationen der Reihe nach ein
|
||||||
|
* und prüft anschließend, ob das Ergebnis genau dem Schema entspricht. Damit
|
||||||
|
* fällt auf, wenn jemand das Schema ändert, ohne eine Migration zu erzeugen —
|
||||||
|
* beim Aufspielen im Betrieb wäre das sonst erst spät zu merken.
|
||||||
|
*
|
||||||
|
* Aufruf: npm run test:migration
|
||||||
|
*/
|
||||||
|
import 'dotenv/config'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { Client } from 'pg'
|
||||||
|
|
||||||
|
const WURZEL = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
||||||
|
|
||||||
|
const verbindung = process.env.DATABASE_URL
|
||||||
|
if (!verbindung) {
|
||||||
|
console.error('DATABASE_URL ist nicht gesetzt.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testDatenbank = `planer_migrationstest_${Date.now()}`
|
||||||
|
|
||||||
|
/** Dieselbe Verbindung, aber auf eine andere Datenbank gerichtet. */
|
||||||
|
function mitDatenbank(url: string, name: string): string {
|
||||||
|
const zerlegt = new URL(url)
|
||||||
|
zerlegt.pathname = `/${name}`
|
||||||
|
return zerlegt.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function prisma(argumente: string[], zusatz: Record<string, string>): number {
|
||||||
|
const ergebnis = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js'), ...argumente],
|
||||||
|
{
|
||||||
|
cwd: WURZEL,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, ...zusatz },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return ergebnis.status ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aufVerwaltungsdatenbank<T>(
|
||||||
|
tun: (klient: Client) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
// "postgres" ist auf jedem Server vorhanden und wird hier nur benutzt, um
|
||||||
|
// die Wegwerf-Datenbank anzulegen und wieder zu entfernen.
|
||||||
|
const klient = new Client({ connectionString: mitDatenbank(verbindung!, 'postgres') })
|
||||||
|
await klient.connect()
|
||||||
|
try {
|
||||||
|
return await tun(klient)
|
||||||
|
} finally {
|
||||||
|
await klient.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} anlegen`)
|
||||||
|
await aufVerwaltungsdatenbank((k) => k.query(`CREATE DATABASE "${testDatenbank}"`))
|
||||||
|
|
||||||
|
const testUrl = mitDatenbank(verbindung!, testDatenbank)
|
||||||
|
|
||||||
|
console.log('▸ Alle Migrationen einspielen')
|
||||||
|
if (prisma(['migrate', 'deploy'], { DATABASE_URL: testUrl }) !== 0) {
|
||||||
|
console.error('✗ Die Migrationen ließen sich nicht einspielen.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('▸ Ergebnis mit dem Schema vergleichen')
|
||||||
|
// --exit-code liefert 2, wenn ein Unterschied bleibt.
|
||||||
|
const abweichung = prisma(
|
||||||
|
[
|
||||||
|
'migrate',
|
||||||
|
'diff',
|
||||||
|
// Die Quelle ist die eben eingespielte Wegwerf-Datenbank, das Ziel das
|
||||||
|
// Schema. So braucht der Vergleich keine Schattendatenbank.
|
||||||
|
'--from-config-datasource',
|
||||||
|
'--to-schema',
|
||||||
|
'prisma/schema.prisma',
|
||||||
|
'--exit-code',
|
||||||
|
],
|
||||||
|
{ DATABASE_URL: testUrl },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (abweichung === 2) {
|
||||||
|
console.error(
|
||||||
|
'\n✗ Nach dem Einspielen weicht die Datenbank vom Schema ab.\n' +
|
||||||
|
' Vermutlich fehlt eine Migration: "npx prisma migrate dev --name <name>".',
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (abweichung !== 0) {
|
||||||
|
console.error('✗ Der Vergleich ließ sich nicht durchführen.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✓ Die Migrationen ergeben genau das Schema.')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 1
|
||||||
|
try {
|
||||||
|
code = await main()
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error('Der Migrationstest ist abgebrochen:', fehler)
|
||||||
|
} finally {
|
||||||
|
await aufVerwaltungsdatenbank(async (k) => {
|
||||||
|
// Offene Verbindungen trennen, sonst lässt sich die Datenbank nicht löschen.
|
||||||
|
await k.query(
|
||||||
|
'SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1',
|
||||||
|
[testDatenbank],
|
||||||
|
)
|
||||||
|
await k.query(`DROP DATABASE IF EXISTS "${testDatenbank}"`)
|
||||||
|
})
|
||||||
|
console.log(`▸ Wegwerf-Datenbank ${testDatenbank} entfernt`)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Sammelpruefung vor jedem Commit: Format, Lint, Typen, Tests, Shellskripte.
|
||||||
|
* Bricht beim ersten Fehler ab und nennt den Schritt, der gefallen ist.
|
||||||
|
*
|
||||||
|
* Die Werkzeuge werden ueber ihren JavaScript-Einstiegspunkt gestartet, nicht
|
||||||
|
* ueber npx. Das spart eine Shell und laeuft unter Windows wie unter Linux
|
||||||
|
* gleich.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
||||||
|
import { join, relative, sep } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const istWindows = process.platform === 'win32'
|
||||||
|
|
||||||
|
/** Windows-Pfad in Schraegstrich-Schreibweise, wie Docker sie erwartet. */
|
||||||
|
function mitSchraegstrich(pfad) {
|
||||||
|
return pfad.split(sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein mitgeliefertes Werkzeug im selben Node starten. */
|
||||||
|
function werkzeug(paket, einstieg, argumente) {
|
||||||
|
const ergebnis = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[join(WURZEL, 'node_modules', paket, einstieg), ...argumente],
|
||||||
|
{ cwd: WURZEL, stdio: 'inherit' },
|
||||||
|
)
|
||||||
|
return ergebnis.status ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function programm(befehl, argumente, optionen = {}) {
|
||||||
|
const ergebnis = spawnSync(befehl, argumente, {
|
||||||
|
cwd: WURZEL,
|
||||||
|
stdio: 'inherit',
|
||||||
|
...optionen,
|
||||||
|
})
|
||||||
|
return ergebnis.status ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function vorhanden(befehl) {
|
||||||
|
const ergebnis = spawnSync(istWindows ? 'where' : 'which', [befehl], {
|
||||||
|
cwd: WURZEL,
|
||||||
|
stdio: 'ignore',
|
||||||
|
})
|
||||||
|
return ergebnis.status === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const UEBERGEHEN = new Set([
|
||||||
|
'node_modules',
|
||||||
|
'.git',
|
||||||
|
'.next',
|
||||||
|
'coverage',
|
||||||
|
'logs',
|
||||||
|
'sicherungen',
|
||||||
|
'playwright-report',
|
||||||
|
'test-results',
|
||||||
|
])
|
||||||
|
|
||||||
|
/** Alle .sh-Dateien im Projekt, ohne node_modules und Konsorten. */
|
||||||
|
function shellskripteFinden(verzeichnis = WURZEL, gefunden = []) {
|
||||||
|
for (const eintrag of readdirSync(verzeichnis)) {
|
||||||
|
if (UEBERGEHEN.has(eintrag)) continue
|
||||||
|
const pfad = join(verzeichnis, eintrag)
|
||||||
|
if (statSync(pfad).isDirectory()) {
|
||||||
|
shellskripteFinden(pfad, gefunden)
|
||||||
|
} else if (eintrag.endsWith('.sh')) {
|
||||||
|
gefunden.push(mitSchraegstrich(relative(WURZEL, pfad)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gefunden
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellskriptePruefen() {
|
||||||
|
const skripte = shellskripteFinden()
|
||||||
|
if (skripte.length === 0) {
|
||||||
|
console.log(' Keine Shellskripte gefunden.')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
console.log(` ${skripte.length} Skript(e): ${skripte.join(', ')}`)
|
||||||
|
const argumente = ['--shell=bash', '--severity=style', ...skripte]
|
||||||
|
|
||||||
|
if (vorhanden('shellcheck')) {
|
||||||
|
return programm('shellcheck', argumente)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (programm('docker', ['info'], { stdio: 'ignore' }) === 0) {
|
||||||
|
console.log(' shellcheck ist nicht installiert, nehme das Docker-Abbild.')
|
||||||
|
return programm('docker', [
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'-v',
|
||||||
|
`${mitSchraegstrich(WURZEL)}:/mnt`,
|
||||||
|
'koalaman/shellcheck:stable',
|
||||||
|
...argumente,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
'Fehler: Weder shellcheck noch ein laufender Docker-Daemon gefunden. ' +
|
||||||
|
'Entweder shellcheck installieren (https://www.shellcheck.net/) oder ' +
|
||||||
|
'Docker starten. Die Shellskripte bleiben sonst ungeprueft.',
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const schritte = [
|
||||||
|
{
|
||||||
|
name: 'Format (Prettier)',
|
||||||
|
tun: () => werkzeug('prettier', 'bin/prettier.cjs', ['--check', '.']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Lint (ESLint)',
|
||||||
|
tun: () => werkzeug('eslint', 'bin/eslint.js', ['.', '--max-warnings', '0']),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Typen (TypeScript)',
|
||||||
|
tun: () => werkzeug('typescript', 'bin/tsc', ['--noEmit']),
|
||||||
|
},
|
||||||
|
{ name: 'Tests (Vitest)', tun: () => werkzeug('vitest', 'vitest.mjs', ['run']) },
|
||||||
|
{ name: 'Shellskripte (ShellCheck)', tun: shellskriptePruefen },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Ohne erzeugten Prisma-Client schlagen Typen und Tests fehl.
|
||||||
|
if (!existsSync(join(WURZEL, 'src', 'generated', 'prisma', 'client.ts'))) {
|
||||||
|
console.log('▸ Prisma-Client fehlt, erzeuge ihn')
|
||||||
|
if (werkzeug('prisma', 'build/index.js', ['generate']) !== 0) process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let gefallen = null
|
||||||
|
for (const schritt of schritte) {
|
||||||
|
console.log(`▸ ${schritt.name}`)
|
||||||
|
if (schritt.tun() !== 0) {
|
||||||
|
gefallen = schritt.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gefallen) {
|
||||||
|
console.error(`✗ Gescheitert: ${gefallen}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✓ Alle Pruefungen bestanden.')
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Setzt eine neue Version: package.json, CHANGELOG, Commit und Tag.
|
||||||
|
# Pusht bewusst nicht – das geschieht erst nach ausdrücklicher Freigabe.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$WURZEL"
|
||||||
|
|
||||||
|
meldung() { printf '▸ %s\n' "$1"; }
|
||||||
|
fehler() {
|
||||||
|
printf 'Fehler: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
hilfe() {
|
||||||
|
cat <<'ENDE'
|
||||||
|
Verwendung: scripts/release.sh <version>
|
||||||
|
|
||||||
|
<version> Semantische Version ohne führendes v, z. B. 0.1.0
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
1. Sauberen Arbeitsbaum und grüne Prüfungen sicherstellen
|
||||||
|
2. Version in package.json setzen
|
||||||
|
3. "Unveröffentlicht" im CHANGELOG in einen datierten Abschnitt überführen
|
||||||
|
4. chore(release): <version> committen und Tag v<version> anlegen
|
||||||
|
|
||||||
|
Es wird nicht gepusht.
|
||||||
|
ENDE
|
||||||
|
}
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
case "$VERSION" in
|
||||||
|
''|--hilfe|-h) hilfe; [ -z "$VERSION" ] && exit 1; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|
||||||
|
|| fehler "\"$VERSION\" sieht nicht nach einer semantischen Version aus (z. B. 0.1.0)."
|
||||||
|
|
||||||
|
TAG="v$VERSION"
|
||||||
|
|
||||||
|
# --- 1. Voraussetzungen ----------------------------------------------------
|
||||||
|
meldung 'Arbeitsbaum prüfen.'
|
||||||
|
[ -d .git ] || fehler 'Kein Git-Arbeitsbaum.'
|
||||||
|
[ -z "$(git status --porcelain)" ] || fehler 'Der Arbeitsbaum ist nicht sauber.'
|
||||||
|
|
||||||
|
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \
|
||||||
|
&& fehler "Den Tag $TAG gibt es schon."
|
||||||
|
|
||||||
|
[ -f CHANGELOG.md ] || fehler 'CHANGELOG.md fehlt.'
|
||||||
|
grep -q '^## \[Unveröffentlicht\]' CHANGELOG.md \
|
||||||
|
|| fehler 'Im CHANGELOG fehlt der Abschnitt "## [Unveröffentlicht]".'
|
||||||
|
|
||||||
|
INHALT="$(awk '
|
||||||
|
/^## \[Unveröffentlicht\]/ { sammeln = 1; next }
|
||||||
|
sammeln && /^## / { exit }
|
||||||
|
sammeln { print }
|
||||||
|
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
|
||||||
|
[ -n "$INHALT" ] || fehler 'Der Abschnitt "Unveröffentlicht" ist leer.'
|
||||||
|
|
||||||
|
meldung 'Prüfungen laufen lassen.'
|
||||||
|
npm run pruefen || fehler 'Die Prüfungen sind nicht grün.'
|
||||||
|
|
||||||
|
# --- 2. Version setzen -----------------------------------------------------
|
||||||
|
meldung "Version auf $VERSION setzen."
|
||||||
|
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
|
||||||
|
|
||||||
|
# --- 3. CHANGELOG ----------------------------------------------------------
|
||||||
|
meldung 'CHANGELOG fortschreiben.'
|
||||||
|
HEUTE="$(date '+%Y-%m-%d')"
|
||||||
|
TEMP="$(mktemp)"
|
||||||
|
awk -v version="$VERSION" -v heute="$HEUTE" '
|
||||||
|
/^## \[Unveröffentlicht\]/ {
|
||||||
|
print "## [Unveröffentlicht]"
|
||||||
|
print ""
|
||||||
|
print "## [" version "] – " heute
|
||||||
|
next
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' CHANGELOG.md > "$TEMP"
|
||||||
|
mv "$TEMP" CHANGELOG.md
|
||||||
|
|
||||||
|
npx prettier --write package.json CHANGELOG.md >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# --- 4. Commit und Tag -----------------------------------------------------
|
||||||
|
meldung "Commit und Tag $TAG anlegen."
|
||||||
|
git add package.json package-lock.json CHANGELOG.md
|
||||||
|
git commit -m "chore(release): $VERSION"
|
||||||
|
git tag -a "$TAG" -m "Planer $VERSION"
|
||||||
|
|
||||||
|
meldung "Fertig. $TAG ist angelegt, aber nicht gepusht."
|
||||||
|
printf '\nNach der Freigabe:\n'
|
||||||
|
printf ' git push origin %s\n' "$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
printf ' git push origin %s\n' "$TAG"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Nimmt die Bilder für das Handbuch auf.
|
||||||
|
*
|
||||||
|
* Startet eine Wegwerf-Instanz in einem eigenen Compose-Projekt mit eigener
|
||||||
|
* Datenbank, spielt ausschließlich erfundene Daten ein, fährt die Seiten mit
|
||||||
|
* Playwright ab und räumt am Ende alles wieder weg — auch wenn unterwegs etwas
|
||||||
|
* schiefgeht.
|
||||||
|
*
|
||||||
|
* An eine Instanz mit echten Daten kommt dieser Ablauf nicht heran: die
|
||||||
|
* Datenbank entsteht neu, wird nur über ihren eigenen Port angesprochen und
|
||||||
|
* samt Volume wieder entfernt.
|
||||||
|
*
|
||||||
|
* Aufruf: npm run screenshots
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const WURZEL = fileURLToPath(new URL('..', import.meta.url))
|
||||||
|
const PROJEKT = 'planer-screenshots'
|
||||||
|
const COMPOSE_DATEI = 'docker-compose.screenshots.yml'
|
||||||
|
|
||||||
|
const APP_PORT = process.env.SCREENSHOT_APP_PORT ?? '3200'
|
||||||
|
const DB_PORT = process.env.SCREENSHOT_DB_PORT ?? '5455'
|
||||||
|
|
||||||
|
const UMGEBUNG = {
|
||||||
|
...process.env,
|
||||||
|
SCREENSHOT_APP_PORT: APP_PORT,
|
||||||
|
SCREENSHOT_DB_PORT: DB_PORT,
|
||||||
|
}
|
||||||
|
|
||||||
|
function meldung(text: string): void {
|
||||||
|
console.log(`▸ ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function laufen(
|
||||||
|
befehl: string,
|
||||||
|
argumente: string[],
|
||||||
|
zusatz: Record<string, string> = {},
|
||||||
|
): number {
|
||||||
|
const ergebnis = spawnSync(befehl, argumente, {
|
||||||
|
cwd: WURZEL,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...UMGEBUNG, ...zusatz },
|
||||||
|
})
|
||||||
|
return ergebnis.status ?? 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function compose(argumente: string[]): number {
|
||||||
|
return laufen('docker', ['compose', '-p', PROJEKT, '-f', COMPOSE_DATEI, ...argumente])
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(skript: string, zusatz: Record<string, string>): number {
|
||||||
|
return laufen(
|
||||||
|
process.execPath,
|
||||||
|
[`${WURZEL}node_modules/tsx/dist/cli.mjs`, skript],
|
||||||
|
zusatz,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wartenAufGesundheit(sekunden: number): Promise<boolean> {
|
||||||
|
const ziel = `http://127.0.0.1:${APP_PORT}/api/health`
|
||||||
|
const frist = Date.now() + sekunden * 1000
|
||||||
|
|
||||||
|
while (Date.now() < frist) {
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(ziel)
|
||||||
|
if (antwort.ok) {
|
||||||
|
const inhalt = (await antwort.json()) as { status?: string }
|
||||||
|
if (inhalt.status === 'ok') return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Noch nicht da, gleich noch einmal.
|
||||||
|
}
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 2000))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function aufraeumen(): void {
|
||||||
|
meldung('Wegwerf-Instanz entfernen.')
|
||||||
|
compose(['down', '-v', '--remove-orphans'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<number> {
|
||||||
|
if (laufen('docker', ['info']) !== 0) {
|
||||||
|
console.error('Der Docker-Daemon läuft nicht.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reste eines abgebrochenen Laufs zuerst wegräumen.
|
||||||
|
compose(['down', '-v', '--remove-orphans'])
|
||||||
|
|
||||||
|
meldung(`Wegwerf-Instanz bauen und starten (App auf ${APP_PORT}, DB auf ${DB_PORT}).`)
|
||||||
|
if (compose(['up', '-d', '--build']) !== 0) {
|
||||||
|
console.error('Die Wegwerf-Instanz ließ sich nicht starten.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
meldung('Auf die Anwendung warten.')
|
||||||
|
if (!(await wartenAufGesundheit(180))) {
|
||||||
|
console.error('Die Anwendung ist nicht gesund geworden.')
|
||||||
|
compose(['logs', '--tail', '50', 'app'])
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
meldung('Erfundene Daten einspielen.')
|
||||||
|
const demoUrl = `postgresql://demo:demo@127.0.0.1:${DB_PORT}/demo?schema=public`
|
||||||
|
if (node('prisma/demo-seed.ts', { DATABASE_URL: demoUrl, DEMO_MODUS: 'true' }) !== 0) {
|
||||||
|
console.error('Der Demo-Seed ist fehlgeschlagen.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
meldung('Bilder aufnehmen.')
|
||||||
|
const playwright = laufen(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
`${WURZEL}node_modules/@playwright/test/cli.js`,
|
||||||
|
'test',
|
||||||
|
'--config',
|
||||||
|
'playwright.screenshots.config.ts',
|
||||||
|
],
|
||||||
|
{ SCREENSHOT_APP_PORT: APP_PORT },
|
||||||
|
)
|
||||||
|
if (playwright !== 0) {
|
||||||
|
console.error('Die Aufnahme ist fehlgeschlagen.')
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
meldung('Fertig. Die Bilder liegen in docs/bilder/.')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = 1
|
||||||
|
try {
|
||||||
|
code = await main()
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error('Der Screenshot-Lauf ist abgebrochen:', fehler)
|
||||||
|
} finally {
|
||||||
|
aufraeumen()
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Zeiterfassung für den Planer.
|
||||||
|
# Schreibt nach devlog/zeiten.csv: datum;start;ende;minuten;meilenstein;art;notiz
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
CSV="$WURZEL/devlog/zeiten.csv"
|
||||||
|
KOPF='datum;start;ende;minuten;meilenstein;art;notiz'
|
||||||
|
OFFEN="$WURZEL/devlog/.zeit-offen"
|
||||||
|
|
||||||
|
hilfe() {
|
||||||
|
cat <<'ENDE'
|
||||||
|
Verwendung: zeit.sh <befehl> [argumente]
|
||||||
|
|
||||||
|
start <Meilenstein> [Notiz] Eintrag öffnen (Art: claude)
|
||||||
|
stop [Notiz] Offenen Eintrag schließen
|
||||||
|
nachtrag <Meilenstein> <Minuten> <Art> <Notiz>
|
||||||
|
Eigene Zeit nachtragen
|
||||||
|
(Art: review, test, einrichtung)
|
||||||
|
summe [Meilenstein] Summen je Meilenstein und Art
|
||||||
|
status Zeigt, ob ein Eintrag offen ist
|
||||||
|
hilfe Diese Übersicht
|
||||||
|
ENDE
|
||||||
|
}
|
||||||
|
|
||||||
|
fehler() {
|
||||||
|
printf 'Fehler: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Semikolons und Zeilenumbrüche würden die CSV zerlegen.
|
||||||
|
saeubern() {
|
||||||
|
printf '%s' "$1" | tr '\n\r;' ' ' | sed 's/ */ /g; s/^ //; s/ $//'
|
||||||
|
}
|
||||||
|
|
||||||
|
csv_anlegen() {
|
||||||
|
mkdir -p "$(dirname "$CSV")"
|
||||||
|
if [ ! -f "$CSV" ]; then
|
||||||
|
printf '%s\n' "$KOPF" > "$CSV"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minuten seit Mitternacht, damit auch ohne GNU date gerechnet werden kann.
|
||||||
|
zu_minuten() {
|
||||||
|
local uhr="$1"
|
||||||
|
local h="${uhr%%:*}"
|
||||||
|
local m="${uhr##*:}"
|
||||||
|
printf '%s' "$((10#$h * 60 + 10#$m))"
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl_start() {
|
||||||
|
local meilenstein="${1:-}"
|
||||||
|
[ -n "$meilenstein" ] || fehler 'Meilenstein fehlt. Beispiel: zeit.sh start M1 "Gerüst"'
|
||||||
|
shift
|
||||||
|
local notiz
|
||||||
|
notiz="$(saeubern "${*:-}")"
|
||||||
|
|
||||||
|
if [ -f "$OFFEN" ]; then
|
||||||
|
local alt
|
||||||
|
alt="$(cat "$OFFEN")"
|
||||||
|
fehler "Es läuft bereits ein Eintrag ($alt). Erst 'zeit.sh stop'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
csv_anlegen
|
||||||
|
mkdir -p "$(dirname "$OFFEN")"
|
||||||
|
printf '%s;%s;%s;%s\n' "$(date '+%Y-%m-%d')" "$(date '+%H:%M')" \
|
||||||
|
"$(saeubern "$meilenstein")" "$notiz" > "$OFFEN"
|
||||||
|
printf 'Gestartet: %s um %s Uhr%s\n' "$meilenstein" "$(date '+%H:%M')" \
|
||||||
|
"${notiz:+ – $notiz}"
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl_stop() {
|
||||||
|
[ -f "$OFFEN" ] || fehler "Kein offener Eintrag. Erst 'zeit.sh start <Meilenstein>'."
|
||||||
|
|
||||||
|
local zeile datum start meilenstein notiz_start
|
||||||
|
zeile="$(cat "$OFFEN")"
|
||||||
|
IFS=';' read -r datum start meilenstein notiz_start <<< "$zeile"
|
||||||
|
|
||||||
|
local ende notiz
|
||||||
|
ende="$(date '+%H:%M')"
|
||||||
|
notiz="$(saeubern "${*:-}")"
|
||||||
|
[ -n "$notiz" ] || notiz="$notiz_start"
|
||||||
|
|
||||||
|
local von bis minuten
|
||||||
|
von="$(zu_minuten "$start")"
|
||||||
|
bis="$(zu_minuten "$ende")"
|
||||||
|
minuten=$((bis - von))
|
||||||
|
# Über Mitternacht hinweg.
|
||||||
|
[ "$minuten" -ge 0 ] || minuten=$((minuten + 1440))
|
||||||
|
|
||||||
|
csv_anlegen
|
||||||
|
printf '%s;%s;%s;%s;%s;claude;%s\n' \
|
||||||
|
"$datum" "$start" "$ende" "$minuten" "$meilenstein" "$notiz" >> "$CSV"
|
||||||
|
rm -f "$OFFEN"
|
||||||
|
printf 'Gestoppt: %s, %s Minuten (%s–%s)\n' "$meilenstein" "$minuten" "$start" "$ende"
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl_nachtrag() {
|
||||||
|
local meilenstein="${1:-}" minuten="${2:-}" art="${3:-}"
|
||||||
|
[ -n "$meilenstein" ] || fehler 'Meilenstein fehlt.'
|
||||||
|
[ -n "$minuten" ] || fehler 'Minuten fehlen.'
|
||||||
|
[ -n "$art" ] || fehler 'Art fehlt (review, test, einrichtung).'
|
||||||
|
case "$minuten" in
|
||||||
|
''|*[!0-9]*) fehler "Minuten müssen eine ganze Zahl sein, nicht '$minuten'." ;;
|
||||||
|
esac
|
||||||
|
shift 3
|
||||||
|
local notiz
|
||||||
|
notiz="$(saeubern "${*:-}")"
|
||||||
|
[ -n "$notiz" ] || fehler 'Notiz fehlt.'
|
||||||
|
|
||||||
|
csv_anlegen
|
||||||
|
printf '%s;;;%s;%s;%s;%s\n' \
|
||||||
|
"$(date '+%Y-%m-%d')" "$minuten" "$(saeubern "$meilenstein")" \
|
||||||
|
"$(saeubern "$art")" "$notiz" >> "$CSV"
|
||||||
|
printf 'Nachgetragen: %s Minuten auf %s (%s)\n' "$minuten" "$meilenstein" "$art"
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl_summe() {
|
||||||
|
local filter="${1:-}"
|
||||||
|
[ -f "$CSV" ] || fehler "Noch keine Zeiten erfasst ($CSV fehlt)."
|
||||||
|
|
||||||
|
awk -F';' -v filter="$filter" '
|
||||||
|
NR == 1 { next }
|
||||||
|
$5 == "" { next }
|
||||||
|
filter != "" && $5 != filter { next }
|
||||||
|
{
|
||||||
|
schluessel = $5 ";" $6
|
||||||
|
summe[schluessel] += $4
|
||||||
|
je_meilenstein[$5] += $4
|
||||||
|
gesamt += $4
|
||||||
|
if (!($5 in gesehen)) { gesehen[$5] = 1; reihenfolge[++anzahl] = $5 }
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (gesamt == 0) { print "Keine Einträge gefunden."; exit }
|
||||||
|
printf "%-14s %-14s %10s %10s\n", "Meilenstein", "Art", "Minuten", "Stunden"
|
||||||
|
printf "%s\n", "------------------------------------------------------"
|
||||||
|
for (i = 1; i <= anzahl; i++) {
|
||||||
|
m = reihenfolge[i]
|
||||||
|
for (s in summe) {
|
||||||
|
split(s, teil, ";")
|
||||||
|
if (teil[1] == m) {
|
||||||
|
printf "%-14s %-14s %10d %10.2f\n", m, teil[2], summe[s], summe[s] / 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf "%-14s %-14s %10d %10.2f\n", m, "= Summe", je_meilenstein[m], je_meilenstein[m] / 60
|
||||||
|
printf "%s\n", "------------------------------------------------------"
|
||||||
|
}
|
||||||
|
printf "%-29s %10d %10.2f\n", "Gesamt", gesamt, gesamt / 60
|
||||||
|
}
|
||||||
|
' "$CSV"
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl_status() {
|
||||||
|
if [ -f "$OFFEN" ]; then
|
||||||
|
local zeile datum start meilenstein notiz
|
||||||
|
zeile="$(cat "$OFFEN")"
|
||||||
|
IFS=';' read -r datum start meilenstein notiz <<< "$zeile"
|
||||||
|
printf 'Offen seit %s %s Uhr: %s%s\n' "$datum" "$start" "$meilenstein" "${notiz:+ – $notiz}"
|
||||||
|
else
|
||||||
|
printf 'Kein offener Eintrag.\n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
befehl="${1:-hilfe}"
|
||||||
|
shift || true
|
||||||
|
|
||||||
|
case "$befehl" in
|
||||||
|
start) befehl_start "$@" ;;
|
||||||
|
stop) befehl_stop "$@" ;;
|
||||||
|
nachtrag) befehl_nachtrag "$@" ;;
|
||||||
|
summe) befehl_summe "$@" ;;
|
||||||
|
status) befehl_status ;;
|
||||||
|
hilfe|-h|--hilfe|--help) hilfe ;;
|
||||||
|
*) fehler "Unbekannter Befehl '$befehl'. 'zeit.sh hilfe' zeigt die Übersicht." ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { Loader2, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { KAPAZITAET_FELDER, WOCHENTAGE } from '@/lib/bereich'
|
||||||
|
import {
|
||||||
|
bereichAendern,
|
||||||
|
bereichAnlegen,
|
||||||
|
bereichLoeschen,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/bereiche'
|
||||||
|
|
||||||
|
type Werte = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
farbe: string
|
||||||
|
reihenfolge: number
|
||||||
|
aktiv: boolean
|
||||||
|
arbeitsfensterVon: string
|
||||||
|
arbeitsfensterBis: string
|
||||||
|
} & Record<(typeof KAPAZITAET_FELDER)[number], number>
|
||||||
|
|
||||||
|
const LEER = {
|
||||||
|
name: '',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 0,
|
||||||
|
aktiv: true,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '17:00',
|
||||||
|
kapazitaetMo: 0,
|
||||||
|
kapazitaetDi: 0,
|
||||||
|
kapazitaetMi: 0,
|
||||||
|
kapazitaetDo: 0,
|
||||||
|
kapazitaetFr: 0,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
} satisfies Omit<Werte, 'id'>
|
||||||
|
|
||||||
|
export function BereichFormular({ bereich }: { bereich?: Werte }) {
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [offen, setOffen] = useState(!bereich)
|
||||||
|
const werte = bereich ?? LEER
|
||||||
|
|
||||||
|
function verarbeite(antwort: Rueckmeldung, formular: HTMLFormElement) {
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
if (!bereich) formular.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
|
||||||
|
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const formular = ereignis.currentTarget
|
||||||
|
const daten = new FormData(formular)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = bereich
|
||||||
|
? await bereichAendern(bereich.id, daten)
|
||||||
|
: await bereichAnlegen(daten)
|
||||||
|
verarbeite(antwort, formular)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loeschen() {
|
||||||
|
if (!bereich) return
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', bereich.id)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await bereichLoeschen(daten)
|
||||||
|
if (antwort.ok) toast.success(antwort.meldung)
|
||||||
|
else toast.error(antwort.fehler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bereich && !offen) {
|
||||||
|
return (
|
||||||
|
<Button art="umriss" groesse="klein" onClick={() => setOffen(true)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`name-${werte.name}`}>Name</Label>
|
||||||
|
<Input
|
||||||
|
id={`name-${werte.name}`}
|
||||||
|
name="name"
|
||||||
|
defaultValue={werte.name}
|
||||||
|
required
|
||||||
|
aria-invalid={Boolean(felder.name)}
|
||||||
|
aria-describedby={felder.name ? `name-fehler-${werte.name}` : undefined}
|
||||||
|
/>
|
||||||
|
{felder.name && (
|
||||||
|
<p id={`name-fehler-${werte.name}`} className="text-gefahr text-xs">
|
||||||
|
{felder.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`farbe-${werte.name}`}>Farbe</Label>
|
||||||
|
<Input
|
||||||
|
id={`farbe-${werte.name}`}
|
||||||
|
name="farbe"
|
||||||
|
type="color"
|
||||||
|
defaultValue={werte.farbe}
|
||||||
|
className="h-9 w-20 p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`von-${werte.name}`}>Arbeitsfenster von</Label>
|
||||||
|
<Input
|
||||||
|
id={`von-${werte.name}`}
|
||||||
|
name="arbeitsfensterVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={werte.arbeitsfensterVon}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor={`bis-${werte.name}`}>bis</Label>
|
||||||
|
<Input
|
||||||
|
id={`bis-${werte.name}`}
|
||||||
|
name="arbeitsfensterBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={werte.arbeitsfensterBis}
|
||||||
|
required
|
||||||
|
aria-invalid={Boolean(felder.arbeitsfensterBis)}
|
||||||
|
/>
|
||||||
|
{felder.arbeitsfensterBis && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.arbeitsfensterBis}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="mb-2 text-sm font-medium">Tageskapazität in Minuten</legend>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||||
|
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||||
|
<div key={feld} className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor={`${feld}-${werte.name}`} className="text-xs font-normal">
|
||||||
|
{WOCHENTAGE[i]}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${feld}-${werte.name}`}
|
||||||
|
name={feld}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1440}
|
||||||
|
step={15}
|
||||||
|
defaultValue={werte[feld]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{felder.kapazitaetMo && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.kapazitaetMo}</p>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id={`aktiv-${werte.name}`}
|
||||||
|
name="aktiv"
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={werte.aktiv}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`aktiv-${werte.name}`} className="font-normal">
|
||||||
|
In Benutzung
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`reihenfolge-${werte.name}`} className="font-normal">
|
||||||
|
Reihenfolge
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`reihenfolge-${werte.name}`}
|
||||||
|
name="reihenfolge"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
defaultValue={werte.reihenfolge}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{bereich ? 'Speichern' : 'Bereich anlegen'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{bereich && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="leise"
|
||||||
|
onClick={() => setOffen(false)}
|
||||||
|
disabled={laeuft}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="gefahr"
|
||||||
|
onClick={loeschen}
|
||||||
|
disabled={laeuft}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { BereichFormular } from '@/app/(app)/bereiche/bereich-formular'
|
||||||
|
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||||
|
import { KAPAZITAET_FELDER, WOCHENTAGE, wochenKapazitaet } from '@/lib/bereich'
|
||||||
|
import { istAdmin } from '@/lib/rollen'
|
||||||
|
import { alsDauer, alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Bereiche' }
|
||||||
|
|
||||||
|
export default async function BereicheSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/bereiche')
|
||||||
|
const darfAendern = istAdmin(benutzer.rolle)
|
||||||
|
|
||||||
|
const bereiche = await db.bereich.findMany({
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Bereiche</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
Farbe, Arbeitsfenster und Tageskapazität je Lebensbereich. Die Kapazität
|
||||||
|
begrenzt später, wie viel an einem Tag eingeplant werden kann.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{bereiche.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||||
|
Noch keine Bereiche angelegt.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{bereiche.map((bereich) => (
|
||||||
|
<li key={bereich.id}>
|
||||||
|
<Card>
|
||||||
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: bereich.farbe }}
|
||||||
|
/>
|
||||||
|
<CardTitel className="flex-1">{bereich.name}</CardTitel>
|
||||||
|
{!bereich.aktiv && (
|
||||||
|
<span className="bg-gedaempft text-gedaempft-vordergrund rounded-md px-2 py-0.5 text-xs">
|
||||||
|
stillgelegt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="flex flex-col gap-4">
|
||||||
|
<dl className="text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Arbeitsfenster:</dt>
|
||||||
|
<dd>
|
||||||
|
{alsUhrzeit(bereich.arbeitsfensterVon)} bis{' '}
|
||||||
|
{alsUhrzeit(bereich.arbeitsfensterBis)} Uhr
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Woche gesamt:</dt>
|
||||||
|
<dd>{alsDauer(wochenKapazitaet(bereich))}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||||
|
<li
|
||||||
|
key={feld}
|
||||||
|
className="bg-gedaempft rounded-md px-2 py-1 text-xs"
|
||||||
|
title={WOCHENTAGE[i]}
|
||||||
|
>
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{WOCHENTAGE[i]?.slice(0, 2)}
|
||||||
|
</span>{' '}
|
||||||
|
{alsDauer(bereich[feld])}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{darfAendern && (
|
||||||
|
<BereichFormular
|
||||||
|
bereich={{
|
||||||
|
id: bereich.id,
|
||||||
|
name: bereich.name,
|
||||||
|
farbe: bereich.farbe,
|
||||||
|
reihenfolge: bereich.reihenfolge,
|
||||||
|
aktiv: bereich.aktiv,
|
||||||
|
arbeitsfensterVon: alsUhrzeit(bereich.arbeitsfensterVon),
|
||||||
|
arbeitsfensterBis: alsUhrzeit(bereich.arbeitsfensterBis),
|
||||||
|
kapazitaetMo: bereich.kapazitaetMo,
|
||||||
|
kapazitaetDi: bereich.kapazitaetDi,
|
||||||
|
kapazitaetMi: bereich.kapazitaetMi,
|
||||||
|
kapazitaetDo: bereich.kapazitaetDo,
|
||||||
|
kapazitaetFr: bereich.kapazitaetFr,
|
||||||
|
kapazitaetSa: bereich.kapazitaetSa,
|
||||||
|
kapazitaetSo: bereich.kapazitaetSo,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfAendern && (
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel>Neuen Bereich anlegen</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<BereichFormular />
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Einstellungen' }
|
||||||
|
|
||||||
|
export default async function EinstellungenSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const passkeys = await db.passkey.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
gesichert: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Konto</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<dl className="grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
||||||
|
<dt className="text-gedaempft-vordergrund">Name</dt>
|
||||||
|
<dd>{benutzer.name ?? '—'}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">E-Mail</dt>
|
||||||
|
<dd>{benutzer.email}</dd>
|
||||||
|
<dt className="text-gedaempft-vordergrund">Rolle</dt>
|
||||||
|
<dd>{ROLLEN_NAME[benutzer.rolle]}</dd>
|
||||||
|
</dl>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Passkeys</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Mit einem Passkey meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN
|
||||||
|
an – ohne Passwort. Hinterlege am besten zwei, damit du bei Verlust eines
|
||||||
|
Geräts nicht ausgesperrt bist.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<PasskeyVerwaltung
|
||||||
|
passkeys={passkeys.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
bezeichnung: p.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(p.erstelltAm),
|
||||||
|
zuletztBenutzt: p.zuletztBenutzt ? alsDatumUhrzeit(p.zuletztBenutzt) : null,
|
||||||
|
gesichert: p.gesichert,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
import { Fingerprint, Loader2, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { passkeyEntfernen } from '@/server/aktionen/passkeys'
|
||||||
|
|
||||||
|
export type PasskeyAnzeige = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
gesichert: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasskeyVerwaltung({ passkeys }: { passkeys: PasskeyAnzeige[] }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [bezeichnung, setBezeichnung] = useState('')
|
||||||
|
const [legtAn, setLegtAn] = useState(false)
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
|
||||||
|
async function anlegen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const name = bezeichnung.trim()
|
||||||
|
if (!name) {
|
||||||
|
toast.error('Bitte einen Namen für das Gerät angeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLegtAn(true)
|
||||||
|
try {
|
||||||
|
const antwortOptionen = await fetch('/api/passkey/registrierung')
|
||||||
|
if (!antwortOptionen.ok) {
|
||||||
|
toast.error('Die Anfrage wurde abgelehnt.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionen = (await antwortOptionen.json()) as Parameters<
|
||||||
|
typeof startRegistration
|
||||||
|
>[0]['optionsJSON']
|
||||||
|
|
||||||
|
const antwort = await startRegistration({ optionsJSON: optionen })
|
||||||
|
|
||||||
|
const gespeichert = await fetch('/api/passkey/registrierung', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ bezeichnung: name, antwort }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ergebnis = (await gespeichert.json()) as { fehler?: string }
|
||||||
|
if (!gespeichert.ok) {
|
||||||
|
toast.error(ergebnis.fehler ?? 'Der Passkey konnte nicht gespeichert werden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Passkey „${name}" angelegt.`)
|
||||||
|
setBezeichnung('')
|
||||||
|
router.refresh()
|
||||||
|
} catch (fehler) {
|
||||||
|
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||||
|
if (fehler instanceof Error && fehler.name === 'NotAllowedError') return
|
||||||
|
if (fehler instanceof Error && fehler.name === 'InvalidStateError') {
|
||||||
|
toast.error('Dieses Gerät ist hier schon hinterlegt.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.error('Der Passkey konnte nicht angelegt werden.')
|
||||||
|
} finally {
|
||||||
|
setLegtAn(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entfernen(id: string) {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await passkeyEntfernen(daten)
|
||||||
|
if (antwort.ok) {
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{passkeys.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Passkey hinterlegt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{passkeys.map((passkey) => (
|
||||||
|
<li
|
||||||
|
key={passkey.id}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3"
|
||||||
|
>
|
||||||
|
<Fingerprint className="text-gedaempft-vordergrund size-4" aria-hidden />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium">{passkey.bezeichnung}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angelegt {passkey.erstelltAm}
|
||||||
|
{passkey.zuletztBenutzt
|
||||||
|
? ` · zuletzt benutzt ${passkey.zuletztBenutzt}`
|
||||||
|
: ' · noch nicht benutzt'}
|
||||||
|
{passkey.gesichert ? ' · geräteübergreifend' : ' · nur dieses Gerät'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`Passkey ${passkey.bezeichnung} entfernen`}
|
||||||
|
onClick={() => entfernen(passkey.id)}
|
||||||
|
disabled={laeuft}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Entfernen</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void anlegen(e)} className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
<Label htmlFor="passkey-name">Neuen Passkey anlegen</Label>
|
||||||
|
<Input
|
||||||
|
id="passkey-name"
|
||||||
|
value={bezeichnung}
|
||||||
|
onChange={(e) => setBezeichnung(e.target.value)}
|
||||||
|
placeholder="z. B. Diensthandy"
|
||||||
|
maxLength={60}
|
||||||
|
disabled={legtAn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" art="umriss" disabled={legtAn}>
|
||||||
|
{legtAn ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Fingerprint aria-hidden />
|
||||||
|
)}
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Kopfleiste } from '@/components/kopfleiste'
|
||||||
|
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||||
|
import { versionAnzeige } from '@/lib/version'
|
||||||
|
import { angefragterPfad, verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { aktuelleInstanz } from '@/server/instanz'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles unterhalb von (app) setzt eine Anmeldung voraus. Der Riegel sitzt
|
||||||
|
* hier im Layout und zusaetzlich in jeder Server Action – nicht nur in der
|
||||||
|
* Oberflaeche.
|
||||||
|
*/
|
||||||
|
export default async function AngemeldetesLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
// Der Pfad kommt aus der Kopfzeile des Proxys, damit es nach der
|
||||||
|
// Anmeldung dort weitergeht, wo man hinwollte.
|
||||||
|
const benutzer = await verlangeAnmeldung(await angefragterPfad())
|
||||||
|
const instanz = aktuelleInstanz()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<Kopfleiste
|
||||||
|
name={benutzer.name ?? benutzer.email}
|
||||||
|
email={benutzer.email}
|
||||||
|
rolle={benutzer.rolle}
|
||||||
|
instanzName={INSTANZ_NAME[instanz]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||||
|
|
||||||
|
<footer className="text-gedaempft-vordergrund border-rand border-t px-4 py-4 text-center text-xs">
|
||||||
|
Planer {versionAnzeige()} · {INSTANZ_NAME[instanz]}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { wochenKapazitaet } from '@/lib/bereich'
|
||||||
|
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export default async function StartSeite() {
|
||||||
|
const benutzer = await verlangeAnmeldung('/')
|
||||||
|
const heute = jetzt()
|
||||||
|
|
||||||
|
const bereiche = await db.bereich.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(heute)}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||||
|
|
||||||
|
{bereiche.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||||
|
Noch keine Bereiche angelegt.{' '}
|
||||||
|
<Link href="/bereiche" className="text-betont underline underline-offset-4">
|
||||||
|
Jetzt einrichten
|
||||||
|
</Link>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{bereiche.map((bereich) => (
|
||||||
|
<li key={bereich.id}>
|
||||||
|
<Card>
|
||||||
|
<CardKopf className="flex-row items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||||
|
style={{ backgroundColor: bereich.farbe }}
|
||||||
|
/>
|
||||||
|
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||||
|
</p>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||||
|
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { Card, CardInhalt } from '@/components/ui/card'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Protokoll' }
|
||||||
|
|
||||||
|
const SEITENGROESSE = 100
|
||||||
|
|
||||||
|
export default async function ProtokollSeite() {
|
||||||
|
// Zweiter Riegel: das Layout prueft die Anmeldung, hier zusaetzlich die Rolle.
|
||||||
|
await verlangeRolle('ADMIN', '/verwaltung/protokoll')
|
||||||
|
|
||||||
|
const eintraege = await db.auditEintrag.findMany({
|
||||||
|
orderBy: { zeitpunkt: 'desc' },
|
||||||
|
take: SEITENGROESSE,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
benutzerEmail: true,
|
||||||
|
aktion: true,
|
||||||
|
objektTyp: true,
|
||||||
|
objektId: true,
|
||||||
|
details: true,
|
||||||
|
instanz: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
Die letzten {SEITENGROESSE} schreibenden Aktionen. Einträge lassen sich nicht
|
||||||
|
ändern und nicht löschen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||||
|
Noch nichts protokolliert.
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="overflow-x-auto p-0">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-gedaempft-vordergrund border-rand border-b">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Zeitpunkt
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Wer
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Was
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 font-medium">
|
||||||
|
Objekt
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-rand divide-y">
|
||||||
|
{eintraege.map((eintrag) => (
|
||||||
|
<tr key={eintrag.id}>
|
||||||
|
<td className="text-gedaempft-vordergrund px-4 py-2 whitespace-nowrap">
|
||||||
|
{alsDatumUhrzeit(eintrag.zeitpunkt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">{eintrag.benutzerEmail}</td>
|
||||||
|
<td className="px-4 py-2 font-medium">{eintrag.aktion}</td>
|
||||||
|
<td className="text-gedaempft-vordergrund px-4 py-2">
|
||||||
|
{eintrag.objektTyp}
|
||||||
|
{eintrag.details ? (
|
||||||
|
<span className="ml-2 text-xs">
|
||||||
|
{JSON.stringify(eintrag.details)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
|
import { Fingerprint, KeyRound, Loader2 } from 'lucide-react'
|
||||||
|
import { signIn } from 'next-auth/react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardInhalt } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
ziel: string
|
||||||
|
oidcAktiv: boolean
|
||||||
|
oidcName: string
|
||||||
|
passwortAktiv: boolean
|
||||||
|
fehlerAusUrl?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst dieselbe Meldung für „gibt es nicht", „falsches Passwort" und
|
||||||
|
* „gesperrt". Sonst lässt sich abfragen, wer hier ein Konto hat.
|
||||||
|
*/
|
||||||
|
const ABGELEHNT = 'Anmeldung nicht möglich. Bitte Angaben prüfen.'
|
||||||
|
|
||||||
|
export function AnmeldeFormular({
|
||||||
|
ziel,
|
||||||
|
oidcAktiv,
|
||||||
|
oidcName,
|
||||||
|
passwortAktiv,
|
||||||
|
fehlerAusUrl,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [passwort, setPasswort] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState<null | 'oidc' | 'passkey' | 'passwort'>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fehlerAusUrl) toast.error(ABGELEHNT)
|
||||||
|
}, [fehlerAusUrl])
|
||||||
|
|
||||||
|
async function mitOidc() {
|
||||||
|
setLaeuft('oidc')
|
||||||
|
await signIn('oidc', { callbackUrl: ziel })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mitPasskey(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
if (!email.trim()) {
|
||||||
|
toast.error('Bitte zuerst die E-Mail-Adresse eintragen.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLaeuft('passkey')
|
||||||
|
try {
|
||||||
|
const antwortOptionen = await fetch('/api/passkey/anmeldung', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
if (!antwortOptionen.ok) {
|
||||||
|
toast.error(ABGELEHNT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionen = (await antwortOptionen.json()) as Parameters<
|
||||||
|
typeof startAuthentication
|
||||||
|
>[0]['optionsJSON']
|
||||||
|
|
||||||
|
const antwort = await startAuthentication({ optionsJSON: optionen })
|
||||||
|
|
||||||
|
const ergebnis = await signIn('passkey', {
|
||||||
|
email,
|
||||||
|
antwort: JSON.stringify(antwort),
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ergebnis?.error) {
|
||||||
|
toast.error(ABGELEHNT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(ziel)
|
||||||
|
router.refresh()
|
||||||
|
} catch (fehler) {
|
||||||
|
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||||
|
if (fehler instanceof Error && fehler.name === 'NotAllowedError') return
|
||||||
|
toast.error('Die Anmeldung mit Passkey hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setLaeuft(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mitPasswort(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
setLaeuft('passwort')
|
||||||
|
try {
|
||||||
|
const ergebnis = await signIn('passwort', {
|
||||||
|
email,
|
||||||
|
passwort,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
if (ergebnis?.error) {
|
||||||
|
toast.error(ABGELEHNT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(ziel)
|
||||||
|
router.refresh()
|
||||||
|
} finally {
|
||||||
|
setLaeuft(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gesperrt = laeuft !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardInhalt className="flex flex-col gap-4 pt-6">
|
||||||
|
{oidcAktiv && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
groesse="gross"
|
||||||
|
onClick={() => void mitOidc()}
|
||||||
|
disabled={gesperrt}
|
||||||
|
>
|
||||||
|
{laeuft === 'oidc' ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<KeyRound aria-hidden />
|
||||||
|
)}
|
||||||
|
Mit {oidcName} anmelden
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-gedaempft-vordergrund text-xs">oder</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={(e) => void mitPasskey(e)}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username webauthn"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={gesperrt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" art="umriss" groesse="gross" disabled={gesperrt}>
|
||||||
|
{laeuft === 'passkey' ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Fingerprint aria-hidden />
|
||||||
|
)}
|
||||||
|
Mit Passkey anmelden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{passwortAktiv && (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={(e) => void mitPasswort(e)}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="passwort">Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="passwort"
|
||||||
|
name="passwort"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={passwort}
|
||||||
|
onChange={(e) => setPasswort(e.target.value)}
|
||||||
|
disabled={gesperrt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" art="leise" disabled={gesperrt}>
|
||||||
|
{laeuft === 'passwort' && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Mit Passwort anmelden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { AnmeldeFormular } from '@/app/anmelden/anmelde-formular'
|
||||||
|
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||||
|
import { versionAnzeige } from '@/lib/version'
|
||||||
|
import { angemeldet } from '@/server/berechtigung'
|
||||||
|
import { aktuelleInstanz } from '@/server/instanz'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Anmelden' }
|
||||||
|
|
||||||
|
/** Nur Pfade innerhalb der App sind als Ziel erlaubt – keine fremden Adressen. */
|
||||||
|
function sicheresZiel(weiter: string | undefined): string {
|
||||||
|
if (!weiter) return '/'
|
||||||
|
if (!weiter.startsWith('/') || weiter.startsWith('//')) return '/'
|
||||||
|
return weiter
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AnmeldeSeite({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ weiter?: string; error?: string }>
|
||||||
|
}) {
|
||||||
|
const parameter = await searchParams
|
||||||
|
const ziel = sicheresZiel(parameter.weiter)
|
||||||
|
|
||||||
|
if (await angemeldet()) {
|
||||||
|
redirect(ziel)
|
||||||
|
}
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
const instanz = aktuelleInstanz()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 px-4 py-10">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Planer</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||||
|
{INSTANZ_NAME[instanz]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnmeldeFormular
|
||||||
|
ziel={ziel}
|
||||||
|
oidcAktiv={einst.oidcAktiv}
|
||||||
|
oidcName={einst.OIDC_NAME || 'Unternehmensanmeldung'}
|
||||||
|
passwortAktiv={einst.ANMELDUNG_PASSWORT}
|
||||||
|
fehlerAusUrl={parameter.error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">{versionAnzeige()}</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from '@/server/auth'
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { VERSION } from '@/lib/version'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { aktuelleInstanz } from '@/server/instanz'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auskunft fuer Betrieb und update.sh. Bewusst ohne Anmeldung, deshalb steht
|
||||||
|
* hier nichts, was nicht ohnehin oeffentlich sein darf – insbesondere keine
|
||||||
|
* Zaehlstaende, Namen oder Adressen.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
let datenbank: 'ok' | 'fehler' = 'ok'
|
||||||
|
try {
|
||||||
|
await db.$queryRaw`SELECT 1`
|
||||||
|
} catch {
|
||||||
|
datenbank = 'fehler'
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = datenbank === 'ok' ? 'ok' : 'fehler'
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
version: VERSION,
|
||||||
|
instanz: aktuelleInstanz(),
|
||||||
|
datenbank,
|
||||||
|
zeit: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: status === 'ok' ? 200 : 503,
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { anmeldeOptionen } from '@/server/auth/passkey'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const eingabe = z.object({ email: z.email() })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die WebAuthn-Optionen fuer die Anmeldung.
|
||||||
|
*
|
||||||
|
* Antwortet fuer unbekannte Adressen genauso wie fuer bekannte – sonst
|
||||||
|
* liesse sich hier abfragen, wer ein Konto hat.
|
||||||
|
*/
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = eingabe.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Bitte eine gültige E-Mail-Adresse angeben.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionen = await anmeldeOptionen(gelesen.data.email)
|
||||||
|
return NextResponse.json(optionen, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { RegistrationResponseJSON } from '@simplewebauthn/server'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { registrierungPruefen, registrierungsOptionen } from '@/server/auth/passkey'
|
||||||
|
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const abschluss = z.object({
|
||||||
|
bezeichnung: z.string().trim().min(1).max(60),
|
||||||
|
antwort: z.looseObject({ id: z.string().min(1) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Schritt 1: Optionen fuer einen neuen Passkey des angemeldeten Benutzers. */
|
||||||
|
export async function GET() {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionen = await registrierungsOptionen(benutzer.id)
|
||||||
|
return NextResponse.json(optionen, { headers: { 'Cache-Control': 'no-store' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 2: Antwort des Browsers pruefen und den Passkey speichern. */
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
const benutzer = await verlangeAnmeldungFuerApi()
|
||||||
|
if (!benutzer) {
|
||||||
|
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = await anfrage.json()
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = abschluss.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: 'Bitte einen Namen für das Gerät angeben.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ergebnis = await registrierungPruefen(
|
||||||
|
benutzer.id,
|
||||||
|
gelesen.data.antwort as unknown as RegistrationResponseJSON,
|
||||||
|
gelesen.data.bezeichnung,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: benutzer.id, email: benutzer.email },
|
||||||
|
aktion: 'passkey.angelegt',
|
||||||
|
objektTyp: 'Passkey',
|
||||||
|
objektId: ergebnis.passkeyId,
|
||||||
|
details: { bezeichnung: ergebnis.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, bezeichnung: ergebnis.bezeichnung })
|
||||||
|
} catch (fehler) {
|
||||||
|
const meldung =
|
||||||
|
fehler instanceof Error
|
||||||
|
? fehler.message
|
||||||
|
: 'Der Passkey konnte nicht gespeichert werden.'
|
||||||
|
return NextResponse.json({ fehler: meldung }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Farben als OKLCH, damit Hell und Dunkel dieselbe wahrgenommene Helligkeit
|
||||||
|
* halten. Die Bereichsfarben kommen aus der Datenbank und werden zur Laufzeit
|
||||||
|
* gesetzt, nicht hier.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
|
||||||
|
--hintergrund: oklch(0.99 0.002 250);
|
||||||
|
--vordergrund: oklch(0.21 0.02 260);
|
||||||
|
--karte: oklch(1 0 0);
|
||||||
|
--karte-vordergrund: oklch(0.21 0.02 260);
|
||||||
|
--gedaempft: oklch(0.96 0.004 250);
|
||||||
|
--gedaempft-vordergrund: oklch(0.5 0.015 260);
|
||||||
|
--rand: oklch(0.91 0.006 255);
|
||||||
|
--eingabe: oklch(0.91 0.006 255);
|
||||||
|
--ring: oklch(0.55 0.16 258);
|
||||||
|
|
||||||
|
--betont: oklch(0.52 0.17 258);
|
||||||
|
--betont-vordergrund: oklch(0.99 0.002 250);
|
||||||
|
--gefahr: oklch(0.55 0.21 27);
|
||||||
|
--gefahr-vordergrund: oklch(0.99 0.002 250);
|
||||||
|
--erfolg: oklch(0.6 0.13 155);
|
||||||
|
--warnung: oklch(0.7 0.15 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--hintergrund: oklch(0.17 0.015 262);
|
||||||
|
--vordergrund: oklch(0.95 0.005 250);
|
||||||
|
--karte: oklch(0.21 0.018 262);
|
||||||
|
--karte-vordergrund: oklch(0.95 0.005 250);
|
||||||
|
--gedaempft: oklch(0.26 0.018 262);
|
||||||
|
--gedaempft-vordergrund: oklch(0.7 0.015 258);
|
||||||
|
--rand: oklch(0.31 0.018 262);
|
||||||
|
--eingabe: oklch(0.31 0.018 262);
|
||||||
|
--ring: oklch(0.62 0.15 258);
|
||||||
|
|
||||||
|
--betont: oklch(0.62 0.16 258);
|
||||||
|
--betont-vordergrund: oklch(0.17 0.015 262);
|
||||||
|
--gefahr: oklch(0.62 0.19 25);
|
||||||
|
--gefahr-vordergrund: oklch(0.98 0.002 250);
|
||||||
|
--erfolg: oklch(0.68 0.12 155);
|
||||||
|
--warnung: oklch(0.78 0.13 75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-hintergrund: var(--hintergrund);
|
||||||
|
--color-vordergrund: var(--vordergrund);
|
||||||
|
--color-karte: var(--karte);
|
||||||
|
--color-karte-vordergrund: var(--karte-vordergrund);
|
||||||
|
--color-gedaempft: var(--gedaempft);
|
||||||
|
--color-gedaempft-vordergrund: var(--gedaempft-vordergrund);
|
||||||
|
--color-rand: var(--rand);
|
||||||
|
--color-eingabe: var(--eingabe);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-betont: var(--betont);
|
||||||
|
--color-betont-vordergrund: var(--betont-vordergrund);
|
||||||
|
--color-gefahr: var(--gefahr);
|
||||||
|
--color-gefahr-vordergrund: var(--gefahr-vordergrund);
|
||||||
|
--color-erfolg: var(--erfolg);
|
||||||
|
--color-warnung: var(--warnung);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-rand outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-hintergrund text-vordergrund;
|
||||||
|
font-feature-settings:
|
||||||
|
'liga' 1,
|
||||||
|
'calt' 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wer mit der Tastatur bedient, muss sehen, wo er ist. */
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-ring outline-2 outline-offset-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ShieldAlert } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Keine Berechtigung' }
|
||||||
|
|
||||||
|
export default function KeineBerechtigungSeite() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
|
<ShieldAlert className="text-gedaempft-vordergrund size-10" aria-hidden />
|
||||||
|
<h1 className="text-xl font-semibold">Dazu fehlt dir die Berechtigung</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund max-w-sm text-sm">
|
||||||
|
Diese Seite ist der Verwaltung vorbehalten. Wenn du meinst, dass das ein Versehen
|
||||||
|
ist, wende dich an die Verwaltung dieser Instanz.
|
||||||
|
</p>
|
||||||
|
<Button alsKind art="umriss">
|
||||||
|
<Link href="/">Zurück zum Start</Link>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { Toaster } from 'sonner'
|
||||||
|
import { FarbschemaAnbieter } from '@/components/farbschema-anbieter'
|
||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: 'Planer',
|
||||||
|
template: '%s – Planer',
|
||||||
|
},
|
||||||
|
description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.',
|
||||||
|
applicationName: 'Planer',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#1a1c22' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WurzelLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="de" suppressHydrationWarning>
|
||||||
|
<body className="min-h-dvh antialiased">
|
||||||
|
<FarbschemaAnbieter>
|
||||||
|
{children}
|
||||||
|
<Toaster position="bottom-right" richColors closeButton />
|
||||||
|
</FarbschemaAnbieter>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Nicht gefunden' }
|
||||||
|
|
||||||
|
export default function NichtGefunden() {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">Fehler 404</p>
|
||||||
|
<h1 className="text-xl font-semibold">Diese Seite gibt es nicht</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund max-w-sm text-sm">
|
||||||
|
Möglicherweise ist sie in dieser Instanz auch gar nicht vorgesehen.
|
||||||
|
</p>
|
||||||
|
<Button alsKind art="umriss">
|
||||||
|
<Link href="/">Zurück zum Start</Link>
|
||||||
|
</Button>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hell, Dunkel oder nach Systemeinstellung. Die Wahl liegt im Browser des
|
||||||
|
* Benutzers, nicht in der Datenbank.
|
||||||
|
*/
|
||||||
|
export function FarbschemaAnbieter({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { LogOut, Moon, Shield, Sun } from 'lucide-react'
|
||||||
|
import { signOut } from 'next-auth/react'
|
||||||
|
import type { Rolle } from '@/generated/prisma/enums'
|
||||||
|
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean }
|
||||||
|
|
||||||
|
const EINTRAEGE: Eintrag[] = [
|
||||||
|
{ pfad: '/', name: 'Start' },
|
||||||
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
|
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Kopfleiste({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
rolle,
|
||||||
|
instanzName,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
rolle: Rolle
|
||||||
|
instanzName: string
|
||||||
|
}) {
|
||||||
|
const pfad = usePathname()
|
||||||
|
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-rand bg-karte border-b">
|
||||||
|
{/*
|
||||||
|
Bewusst eine einzige Zeile auf allen Breiten: Wird es eng, scrollt die
|
||||||
|
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||||||
|
die zweite Zeile zu drängen.
|
||||||
|
*/}
|
||||||
|
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
|
||||||
|
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||||||
|
Planer
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label="Hauptnavigation"
|
||||||
|
className="flex min-w-0 flex-1 gap-1 overflow-x-auto"
|
||||||
|
>
|
||||||
|
{sichtbar.map((eintrag) => {
|
||||||
|
const aktiv =
|
||||||
|
eintrag.pfad === '/' ? pfad === '/' : pfad.startsWith(eintrag.pfad)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={eintrag.pfad}
|
||||||
|
href={eintrag.pfad}
|
||||||
|
aria-current={aktiv ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
|
||||||
|
aktiv
|
||||||
|
? 'bg-gedaempft text-vordergrund font-medium'
|
||||||
|
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{eintrag.nurAdmin && (
|
||||||
|
<Shield className="mr-1 inline size-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{eintrag.name}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<FarbschemaKnopf />
|
||||||
|
<span
|
||||||
|
className="text-gedaempft-vordergrund hidden text-xs sm:inline"
|
||||||
|
title={`${email} · ${ROLLEN_NAME[rolle]} · ${instanzName}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Abmelden"
|
||||||
|
onClick={() => void signOut({ callbackUrl: '/anmelden' })}
|
||||||
|
>
|
||||||
|
<LogOut aria-hidden />
|
||||||
|
<span className="sr-only">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FarbschemaKnopf() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
// Welches Symbol gilt, entscheidet die dark-Klasse und damit CSS. next-themes
|
||||||
|
// setzt sie schon vor der Hydratation, deshalb braucht es hier weder einen
|
||||||
|
// Effekt noch einen Zustand – und es blitzt nichts auf.
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Farbschema wechseln"
|
||||||
|
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||||
|
>
|
||||||
|
<Moon className="dark:hidden" aria-hidden />
|
||||||
|
<Sun className="hidden dark:block" aria-hidden />
|
||||||
|
<span className="sr-only">Farbschema wechseln</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const knopfStile = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
art: {
|
||||||
|
betont: 'bg-betont text-betont-vordergrund hover:bg-betont/90',
|
||||||
|
umriss: 'border border-rand bg-karte hover:bg-gedaempft',
|
||||||
|
leise: 'hover:bg-gedaempft',
|
||||||
|
gefahr: 'bg-gefahr text-gefahr-vordergrund hover:bg-gefahr/90',
|
||||||
|
verweis: 'text-betont underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
groesse: {
|
||||||
|
normal: 'h-9 px-4 py-2',
|
||||||
|
klein: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
gross: 'h-11 rounded-md px-6',
|
||||||
|
quadrat: 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
art: 'betont',
|
||||||
|
groesse: 'normal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type KnopfEigenschaften = React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof knopfStile> & {
|
||||||
|
/** Rendert stattdessen das Kindelement, z. B. einen Link. */
|
||||||
|
alsKind?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
art,
|
||||||
|
groesse,
|
||||||
|
alsKind = false,
|
||||||
|
...rest
|
||||||
|
}: KnopfEigenschaften) {
|
||||||
|
const Element = alsKind ? Slot : 'button'
|
||||||
|
return <Element className={cn(knopfStile({ art, groesse }), className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, knopfStile }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Card({ className, ...rest }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-karte text-karte-vordergrund border-rand rounded-xl border shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardKopf({ className, ...rest }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('flex flex-col gap-1 p-6 pb-4', className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitel({ className, ...rest }: React.ComponentProps<'h2'>) {
|
||||||
|
return (
|
||||||
|
<h2
|
||||||
|
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardBeschreibung({ className, ...rest }: React.ComponentProps<'p'>) {
|
||||||
|
return <p className={cn('text-gedaempft-vordergrund text-sm', className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardInhalt({ className, ...rest }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('p-6 pt-0', className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFuss({ className, ...rest }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('flex items-center gap-2 p-6 pt-0', className)} {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardKopf, CardTitel, CardBeschreibung, CardInhalt, CardFuss }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Input({ className, type, ...rest }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'border-eingabe bg-karte flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-sm shadow-xs transition-colors outline-none',
|
||||||
|
'placeholder:text-gedaempft-vordergrund',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-2',
|
||||||
|
'aria-invalid:border-gefahr aria-invalid:ring-gefahr/30',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Label({ className, ...rest }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
className={cn(
|
||||||
|
'text-sm leading-none font-medium select-none',
|
||||||
|
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...rest
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-rand shrink-0',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ENTFERNT, auditDetails, detailsSaeubern } from './audit'
|
||||||
|
|
||||||
|
describe('auditDetails', () => {
|
||||||
|
it('laesst fachliche Felder unangetastet', () => {
|
||||||
|
expect(auditDetails({ name: 'Gewerbe', farbe: '#2563eb', reihenfolge: 2 })).toEqual({
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#2563eb',
|
||||||
|
reihenfolge: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt fuer nichts auch nichts zurueck', () => {
|
||||||
|
expect(auditDetails(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt Passwoerter und Tokens', () => {
|
||||||
|
expect(
|
||||||
|
auditDetails({
|
||||||
|
email: 'a@b.de',
|
||||||
|
passwort: 'geheim123',
|
||||||
|
passwortHash: '$2b$12$...',
|
||||||
|
access_token: 'ey...',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
email: 'a@b.de',
|
||||||
|
passwort: ENTFERNT,
|
||||||
|
passwortHash: ENTFERNT,
|
||||||
|
access_token: ENTFERNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entfernt Termin-Betreffs und Mailinhalte', () => {
|
||||||
|
expect(
|
||||||
|
auditDetails({ betreff: 'Personalgespräch', body: 'Text', dauer: 30 }),
|
||||||
|
).toEqual({ betreff: ENTFERNT, body: ENTFERNT, dauer: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('achtet nicht auf Gross- und Kleinschreibung', () => {
|
||||||
|
expect(auditDetails({ ClientSecret: 'x', REFRESH_TOKEN: 'y' })).toEqual({
|
||||||
|
ClientSecret: ENTFERNT,
|
||||||
|
REFRESH_TOKEN: ENTFERNT,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detailsSaeubern', () => {
|
||||||
|
it('greift auch in verschachtelten Objekten', () => {
|
||||||
|
expect(detailsSaeubern({ konto: { anbieter: 'GRAPH', id_token: 'ey...' } })).toEqual({
|
||||||
|
konto: { anbieter: 'GRAPH', id_token: ENTFERNT },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('greift in Listen', () => {
|
||||||
|
expect(detailsSaeubern([{ name: 'a', token: 'x' }])).toEqual([
|
||||||
|
{ name: 'a', token: ENTFERNT },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schreibt Datumsangaben als ISO-Zeichenkette', () => {
|
||||||
|
expect(detailsSaeubern({ am: new Date('2026-09-10T12:00:00Z') })).toEqual({
|
||||||
|
am: '2026-09-10T12:00:00.000Z',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('schneidet zu tiefe Verschachtelung ab, statt endlos abzusteigen', () => {
|
||||||
|
expect(detailsSaeubern({ a: { b: { c: { d: { e: { f: 1 } } } } } })).toEqual({
|
||||||
|
a: { b: { c: { d: { e: ENTFERNT } } } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kommt mit einfachen Werten und null zurecht', () => {
|
||||||
|
expect(detailsSaeubern('text')).toBe('text')
|
||||||
|
expect(detailsSaeubern(42)).toBe(42)
|
||||||
|
expect(detailsSaeubern(null)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Aufbereitung der Audit-Details. Reine Logik, damit sie ohne Datenbank
|
||||||
|
* geprueft werden kann.
|
||||||
|
*
|
||||||
|
* In die Details gehoeren nur fachliche Felder – niemals Tokens, Passwoerter,
|
||||||
|
* Mailinhalte oder Termin-Betreffs. Was hier landet, steht spaeter im
|
||||||
|
* Klartext in der Datenbank und in jeder Sicherung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Feldnamen, die auch versehentlich nie in einem Audit-Eintrag landen duerfen. */
|
||||||
|
const VERBOTEN = [
|
||||||
|
'passwort',
|
||||||
|
'password',
|
||||||
|
'token',
|
||||||
|
'secret',
|
||||||
|
'geheim',
|
||||||
|
'clientsecret',
|
||||||
|
'betreff',
|
||||||
|
'subject',
|
||||||
|
'inhalt',
|
||||||
|
'body',
|
||||||
|
'nachricht',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ENTFERNT = '[entfernt]'
|
||||||
|
|
||||||
|
function istVerdaechtig(schluessel: string): boolean {
|
||||||
|
const klein = schluessel.toLowerCase()
|
||||||
|
return VERBOTEN.some((v) => klein.includes(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zweiter Riegel gegen Unachtsamkeit: Felder mit verdaechtigem Namen werden
|
||||||
|
* ersetzt, statt gespeichert zu werden. Wirkt auch in verschachtelten
|
||||||
|
* Objekten und in Listen.
|
||||||
|
*/
|
||||||
|
export function detailsSaeubern(wert: unknown, tiefe = 0): unknown {
|
||||||
|
// Tief verschachtelte Strukturen gehoeren nicht in ein Audit-Detail; ab
|
||||||
|
// einer gewissen Tiefe wird abgeschnitten statt endlos abgestiegen.
|
||||||
|
if (tiefe > 4) return ENTFERNT
|
||||||
|
|
||||||
|
if (Array.isArray(wert)) {
|
||||||
|
return wert.map((eintrag) => detailsSaeubern(eintrag, tiefe + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wert !== null && typeof wert === 'object') {
|
||||||
|
if (wert instanceof Date) return wert.toISOString()
|
||||||
|
const sauber: Record<string, unknown> = {}
|
||||||
|
for (const [schluessel, inhalt] of Object.entries(wert)) {
|
||||||
|
sauber[schluessel] = istVerdaechtig(schluessel)
|
||||||
|
? ENTFERNT
|
||||||
|
: detailsSaeubern(inhalt, tiefe + 1)
|
||||||
|
}
|
||||||
|
return sauber
|
||||||
|
}
|
||||||
|
|
||||||
|
return wert
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Einstieg fuer den Normalfall: ein flaches Objekt. */
|
||||||
|
export function auditDetails(
|
||||||
|
details: Record<string, unknown> | undefined,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
if (!details) return undefined
|
||||||
|
return detailsSaeubern(details) as Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
KAPAZITAET_FELDER,
|
||||||
|
bereichSchema,
|
||||||
|
farbeSchema,
|
||||||
|
kapazitaetAmTag,
|
||||||
|
wochenKapazitaet,
|
||||||
|
} from './bereich'
|
||||||
|
|
||||||
|
const gueltig = {
|
||||||
|
name: 'Gewerbe',
|
||||||
|
farbe: '#2563EB',
|
||||||
|
reihenfolge: 1,
|
||||||
|
aktiv: true,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '17:00',
|
||||||
|
kapazitaetMo: 240,
|
||||||
|
kapazitaetDi: 240,
|
||||||
|
kapazitaetMi: 240,
|
||||||
|
kapazitaetDo: 240,
|
||||||
|
kapazitaetFr: 120,
|
||||||
|
kapazitaetSa: 0,
|
||||||
|
kapazitaetSo: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('farbeSchema', () => {
|
||||||
|
it('nimmt sechsstellige und dreistellige Hex-Farben', () => {
|
||||||
|
expect(farbeSchema.parse('#2563eb')).toBe('#2563eb')
|
||||||
|
expect(farbeSchema.parse('#abc')).toBe('#abc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('macht Grossbuchstaben klein, damit Vergleiche stimmen', () => {
|
||||||
|
expect(farbeSchema.parse('#2563EB')).toBe('#2563eb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist Farben ohne Raute und mit falscher Laenge ab', () => {
|
||||||
|
expect(farbeSchema.safeParse('2563eb').success).toBe(false)
|
||||||
|
expect(farbeSchema.safeParse('#12345').success).toBe(false)
|
||||||
|
expect(farbeSchema.safeParse('blau').success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bereichSchema', () => {
|
||||||
|
it('nimmt einen vollstaendigen Bereich an und rechnet Uhrzeiten in Minuten um', () => {
|
||||||
|
const ergebnis = bereichSchema.parse(gueltig)
|
||||||
|
expect(ergebnis.arbeitsfensterVon).toBe(480)
|
||||||
|
expect(ergebnis.arbeitsfensterBis).toBe(1020)
|
||||||
|
expect(ergebnis.farbe).toBe('#2563eb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt einen Namen mit mindestens zwei Zeichen', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({ ...gueltig, name: 'A' })
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Arbeitsfenster ab, das vor seinem Beginn endet', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
arbeitsfensterVon: '17:00',
|
||||||
|
arbeitsfensterBis: '08:00',
|
||||||
|
})
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
expect(ergebnis.error.issues[0]?.message).toMatch(/nach seinem Beginn enden/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein Arbeitsfenster ohne Dauer ab', () => {
|
||||||
|
const ergebnis = bereichSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
arbeitsfensterVon: '08:00',
|
||||||
|
arbeitsfensterBis: '08:00',
|
||||||
|
})
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine Kapazitaet ab, die laenger ist als das Arbeitsfenster', () => {
|
||||||
|
// Fenster 08:00–17:00 sind 540 Minuten.
|
||||||
|
const ergebnis = bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 600 })
|
||||||
|
expect(ergebnis.success).toBe(false)
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
expect(ergebnis.error.issues[0]?.message).toMatch(/Arbeitsfenster/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst eine Kapazitaet genau in Fensterlaenge zu', () => {
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 540 }).success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist negative und uebergrosse Kapazitaeten ab', () => {
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: -1 }).success).toBe(false)
|
||||||
|
expect(bereichSchema.safeParse({ ...gueltig, kapazitaetMo: 1441 }).success).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist eine unsinnige Uhrzeit ab', () => {
|
||||||
|
expect(
|
||||||
|
bereichSchema.safeParse({ ...gueltig, arbeitsfensterVon: '25:00' }).success,
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wochenKapazitaet', () => {
|
||||||
|
it('summiert alle sieben Tage', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(wochenKapazitaet(bereich)).toBe(1080)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ist null, wenn nichts eingeplant ist', () => {
|
||||||
|
const leer = Object.fromEntries(KAPAZITAET_FELDER.map((f) => [f, 0])) as Record<
|
||||||
|
(typeof KAPAZITAET_FELDER)[number],
|
||||||
|
number
|
||||||
|
>
|
||||||
|
expect(wochenKapazitaet(leer)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kapazitaetAmTag', () => {
|
||||||
|
it('trifft den richtigen Wochentag, Montag ist null', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(kapazitaetAmTag(bereich, 0)).toBe(240)
|
||||||
|
expect(kapazitaetAmTag(bereich, 4)).toBe(120)
|
||||||
|
expect(kapazitaetAmTag(bereich, 6)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt fuer einen Index ausserhalb der Woche null zurueck', () => {
|
||||||
|
const bereich = bereichSchema.parse(gueltig)
|
||||||
|
expect(kapazitaetAmTag(bereich, 7)).toBe(0)
|
||||||
|
expect(kapazitaetAmTag(bereich, -1)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { ausUhrzeit } from '@/lib/zeit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regeln fuer Bereiche. Reine Logik, damit Formular und Server Action
|
||||||
|
* dieselbe Antwort geben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WOCHENTAGE = [
|
||||||
|
'Montag',
|
||||||
|
'Dienstag',
|
||||||
|
'Mittwoch',
|
||||||
|
'Donnerstag',
|
||||||
|
'Freitag',
|
||||||
|
'Samstag',
|
||||||
|
'Sonntag',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const KAPAZITAET_FELDER = [
|
||||||
|
'kapazitaetMo',
|
||||||
|
'kapazitaetDi',
|
||||||
|
'kapazitaetMi',
|
||||||
|
'kapazitaetDo',
|
||||||
|
'kapazitaetFr',
|
||||||
|
'kapazitaetSa',
|
||||||
|
'kapazitaetSo',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type KapazitaetFeld = (typeof KAPAZITAET_FELDER)[number]
|
||||||
|
|
||||||
|
/** Hex-Farbe mit Raute, drei- oder sechsstellig. */
|
||||||
|
export const farbeSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Bitte eine Farbe wie #2563eb angeben.')
|
||||||
|
.transform((f) => f.toLowerCase())
|
||||||
|
|
||||||
|
const uhrzeitSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((w) => ausUhrzeit(w) !== null, 'Bitte eine Uhrzeit wie 08:00 angeben.')
|
||||||
|
.transform((w) => ausUhrzeit(w) as number)
|
||||||
|
|
||||||
|
/** Eine Tageskapazitaet: 0 bis 24 Stunden, in Minuten. */
|
||||||
|
const kapazitaetSchema = z.coerce
|
||||||
|
.number()
|
||||||
|
.int('Bitte ganze Minuten angeben.')
|
||||||
|
.min(0, 'Nicht weniger als 0 Minuten.')
|
||||||
|
.max(1440, 'Nicht mehr als 1440 Minuten, also 24 Stunden.')
|
||||||
|
|
||||||
|
export const bereichSchema = z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2, 'Mindestens zwei Zeichen.')
|
||||||
|
.max(40, 'Höchstens 40 Zeichen.'),
|
||||||
|
farbe: farbeSchema,
|
||||||
|
reihenfolge: z.coerce.number().int().min(0).max(999).default(0),
|
||||||
|
aktiv: z.coerce.boolean().default(true),
|
||||||
|
arbeitsfensterVon: uhrzeitSchema,
|
||||||
|
arbeitsfensterBis: uhrzeitSchema,
|
||||||
|
kapazitaetMo: kapazitaetSchema,
|
||||||
|
kapazitaetDi: kapazitaetSchema,
|
||||||
|
kapazitaetMi: kapazitaetSchema,
|
||||||
|
kapazitaetDo: kapazitaetSchema,
|
||||||
|
kapazitaetFr: kapazitaetSchema,
|
||||||
|
kapazitaetSa: kapazitaetSchema,
|
||||||
|
kapazitaetSo: kapazitaetSchema,
|
||||||
|
})
|
||||||
|
.refine((b) => b.arbeitsfensterVon < b.arbeitsfensterBis, {
|
||||||
|
path: ['arbeitsfensterBis'],
|
||||||
|
message: 'Das Arbeitsfenster muss nach seinem Beginn enden.',
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(b) =>
|
||||||
|
KAPAZITAET_FELDER.every(
|
||||||
|
(feld) => b[feld] <= b.arbeitsfensterBis - b.arbeitsfensterVon,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
path: ['kapazitaetMo'],
|
||||||
|
message: 'Keine Tageskapazität darf länger sein als das Arbeitsfenster.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BereichEingabe = z.infer<typeof bereichSchema>
|
||||||
|
|
||||||
|
/** Summe der Wochenkapazitaet in Minuten. */
|
||||||
|
export function wochenKapazitaet(bereich: Record<KapazitaetFeld, number>): number {
|
||||||
|
return KAPAZITAET_FELDER.reduce((summe, feld) => summe + bereich[feld], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kapazitaet des Wochentags, 0 = Montag. */
|
||||||
|
export function kapazitaetAmTag(
|
||||||
|
bereich: Record<KapazitaetFeld, number>,
|
||||||
|
wochentagIndex: number,
|
||||||
|
): number {
|
||||||
|
const feld = KAPAZITAET_FELDER[wochentagIndex]
|
||||||
|
return feld ? bereich[feld] : 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
FAEHIGKEITEN,
|
||||||
|
INSTANZEN,
|
||||||
|
faehigkeitenVon,
|
||||||
|
istErlaubt,
|
||||||
|
istInstanz,
|
||||||
|
kalenderAnbieterVon,
|
||||||
|
leseInstanz,
|
||||||
|
} from './instanz'
|
||||||
|
|
||||||
|
describe('leseInstanz', () => {
|
||||||
|
it('nimmt die erlaubten Werte an', () => {
|
||||||
|
expect(leseInstanz('firma')).toBe('firma')
|
||||||
|
expect(leseInstanz('daheim')).toBe('daheim')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ist unempfindlich gegen Gross- und Kleinschreibung und Leerzeichen', () => {
|
||||||
|
expect(leseInstanz(' FIRMA ')).toBe('firma')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bricht ab, wenn nichts gesetzt ist', () => {
|
||||||
|
expect(() => leseInstanz(undefined)).toThrow(/nicht gesetzt/)
|
||||||
|
expect(() => leseInstanz(' ')).toThrow(/nicht gesetzt/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('faellt bei einem Tippfehler nicht still auf einen Standard zurueck', () => {
|
||||||
|
expect(() => leseInstanz('fimra')).toThrow(/unbekannten Wert/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istInstanz', () => {
|
||||||
|
it('erkennt gueltige Werte', () => {
|
||||||
|
expect(istInstanz('firma')).toBe(true)
|
||||||
|
expect(istInstanz('daheim')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist alles andere ab', () => {
|
||||||
|
expect(istInstanz('privat')).toBe(false)
|
||||||
|
expect(istInstanz(undefined)).toBe(false)
|
||||||
|
expect(istInstanz(42)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istErlaubt', () => {
|
||||||
|
it('trennt die Kalenderanbieter der beiden Instanzen', () => {
|
||||||
|
expect(istErlaubt('firma', 'kalender:graph')).toBe(true)
|
||||||
|
expect(istErlaubt('firma', 'kalender:google')).toBe(false)
|
||||||
|
expect(istErlaubt('daheim', 'kalender:google')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('haelt die Integrationen auseinander', () => {
|
||||||
|
expect(istErlaubt('firma', 'integration:zammad')).toBe(true)
|
||||||
|
expect(istErlaubt('daheim', 'integration:zammad')).toBe(false)
|
||||||
|
expect(istErlaubt('daheim', 'integration:telegram')).toBe(true)
|
||||||
|
expect(istErlaubt('firma', 'integration:telegram')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('richtet den Spiegel in genau eine Richtung ein', () => {
|
||||||
|
expect(istErlaubt('firma', 'spiegel:senden')).toBe(true)
|
||||||
|
expect(istErlaubt('firma', 'spiegel:empfangen')).toBe(false)
|
||||||
|
expect(istErlaubt('daheim', 'spiegel:empfangen')).toBe(true)
|
||||||
|
expect(istErlaubt('daheim', 'spiegel:senden')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('faehigkeitenVon', () => {
|
||||||
|
it('liefert nur bekannte Faehigkeiten', () => {
|
||||||
|
for (const instanz of INSTANZEN) {
|
||||||
|
for (const faehigkeit of faehigkeitenVon(instanz)) {
|
||||||
|
expect(FAEHIGKEITEN).toContain(faehigkeit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gibt keiner Instanz alles', () => {
|
||||||
|
for (const instanz of INSTANZEN) {
|
||||||
|
expect(faehigkeitenVon(instanz).length).toBeLessThan(FAEHIGKEITEN.length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('kalenderAnbieterVon', () => {
|
||||||
|
it('nennt fuer die Firma nur Graph', () => {
|
||||||
|
expect(kalenderAnbieterVon('firma')).toEqual(['GRAPH'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nennt daheim beide', () => {
|
||||||
|
expect(kalenderAnbieterVon('daheim')).toEqual(['GRAPH', 'GOOGLE'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Instanzsteuerung.
|
||||||
|
*
|
||||||
|
* Ein Image, zwei Deployments. Welche Provider, Integrationen und Routen
|
||||||
|
* erlaubt sind, haengt allein an INSTANZ und wird serverseitig erzwungen –
|
||||||
|
* nicht in der Oberflaeche versteckt.
|
||||||
|
*
|
||||||
|
* Diese Datei ist reine Logik: kein Zugriff auf process.env, keine Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INSTANZEN = ['firma', 'daheim'] as const
|
||||||
|
export type Instanz = (typeof INSTANZEN)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alles, was je nach Instanz an- oder abgeschaltet ist. Eine Faehigkeit, die
|
||||||
|
* hier nicht steht, gibt es ueberall.
|
||||||
|
*/
|
||||||
|
export const FAEHIGKEITEN = [
|
||||||
|
// Kalender- und Mailanbindung
|
||||||
|
'kalender:graph',
|
||||||
|
'kalender:google',
|
||||||
|
'mail:graph',
|
||||||
|
'mail:gmail',
|
||||||
|
// Integrationen
|
||||||
|
'integration:zammad',
|
||||||
|
'integration:wartungsplaner',
|
||||||
|
'integration:telegram',
|
||||||
|
'spiegel:senden',
|
||||||
|
'spiegel:empfangen',
|
||||||
|
] as const
|
||||||
|
export type Faehigkeit = (typeof FAEHIGKEITEN)[number]
|
||||||
|
|
||||||
|
const ERLAUBT: Readonly<Record<Instanz, readonly Faehigkeit[]>> = {
|
||||||
|
firma: [
|
||||||
|
'kalender:graph',
|
||||||
|
'mail:graph',
|
||||||
|
'integration:zammad',
|
||||||
|
'integration:wartungsplaner',
|
||||||
|
'spiegel:senden',
|
||||||
|
],
|
||||||
|
daheim: [
|
||||||
|
'kalender:graph',
|
||||||
|
'kalender:google',
|
||||||
|
'mail:graph',
|
||||||
|
'mail:gmail',
|
||||||
|
'integration:telegram',
|
||||||
|
'spiegel:empfangen',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename der Instanz fuer die Oberflaeche. */
|
||||||
|
export const INSTANZ_NAME: Readonly<Record<Instanz, string>> = {
|
||||||
|
firma: 'Firma',
|
||||||
|
daheim: 'Daheim',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istInstanz(wert: unknown): wert is Instanz {
|
||||||
|
return typeof wert === 'string' && (INSTANZEN as readonly string[]).includes(wert)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Konfigurationswert und bricht bei Unsinn ab. Ein falsch
|
||||||
|
* geschriebenes INSTANZ darf nicht stillschweigend zu "daheim" werden –
|
||||||
|
* sonst laufen in der Firma Provider an, die dort nichts zu suchen haben.
|
||||||
|
*/
|
||||||
|
export function leseInstanz(wert: string | undefined): Instanz {
|
||||||
|
const bereinigt = wert?.trim().toLowerCase()
|
||||||
|
if (!bereinigt) {
|
||||||
|
throw new Error('INSTANZ ist nicht gesetzt. Erlaubt: ' + INSTANZEN.join(', '))
|
||||||
|
}
|
||||||
|
if (!istInstanz(bereinigt)) {
|
||||||
|
throw new Error(
|
||||||
|
`INSTANZ hat den unbekannten Wert "${wert ?? ''}". Erlaubt: ` +
|
||||||
|
INSTANZEN.join(', '),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return bereinigt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist diese Faehigkeit in dieser Instanz erlaubt? */
|
||||||
|
export function istErlaubt(instanz: Instanz, faehigkeit: Faehigkeit): boolean {
|
||||||
|
return ERLAUBT[instanz].includes(faehigkeit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle in dieser Instanz erlaubten Faehigkeiten, in fester Reihenfolge. */
|
||||||
|
export function faehigkeitenVon(instanz: Instanz): readonly Faehigkeit[] {
|
||||||
|
return FAEHIGKEITEN.filter((f) => istErlaubt(instanz, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Kalenderanbieter darf diese Instanz verbinden? */
|
||||||
|
export function kalenderAnbieterVon(instanz: Instanz): readonly ('GRAPH' | 'GOOGLE')[] {
|
||||||
|
const anbieter: ('GRAPH' | 'GOOGLE')[] = []
|
||||||
|
if (istErlaubt(instanz, 'kalender:graph')) anbieter.push('GRAPH')
|
||||||
|
if (istErlaubt(instanz, 'kalender:google')) anbieter.push('GOOGLE')
|
||||||
|
return anbieter
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
PASSWORT_HOECHSTLAENGE,
|
||||||
|
PASSWORT_MINDESTLAENGE,
|
||||||
|
passwortIstGut,
|
||||||
|
passwortMaengel,
|
||||||
|
} from './passwort'
|
||||||
|
|
||||||
|
describe('passwortMaengel', () => {
|
||||||
|
it('nimmt ein ausreichend langes Passwort an', () => {
|
||||||
|
expect(passwortMaengel('richtiges pferd batterie klammer')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bemaengelt zu kurze Passwoerter', () => {
|
||||||
|
expect(passwortMaengel('kurz')).toContain(
|
||||||
|
`Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setzt die Grenze bei genau der Mindestlaenge', () => {
|
||||||
|
expect(
|
||||||
|
passwortMaengel('a'.repeat(PASSWORT_MINDESTLAENGE - 1)).length,
|
||||||
|
).toBeGreaterThan(0)
|
||||||
|
// Genau die Mindestlaenge, aber ein einziges wiederholtes Zeichen.
|
||||||
|
expect(passwortMaengel('a'.repeat(PASSWORT_MINDESTLAENGE))).toEqual([
|
||||||
|
'Darf nicht aus einem einzigen wiederholten Zeichen bestehen.',
|
||||||
|
])
|
||||||
|
expect(passwortMaengel('abcdefghijkl')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ueberlange Eingaben ab, damit das Hashen begrenzt bleibt', () => {
|
||||||
|
expect(passwortMaengel('a'.repeat(PASSWORT_HOECHSTLAENGE + 1))).toContain(
|
||||||
|
`Höchstens ${PASSWORT_HOECHSTLAENGE} Zeichen.`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist reine Leerzeichen ab', () => {
|
||||||
|
expect(passwortMaengel(' '.repeat(20))).toContain(
|
||||||
|
'Darf nicht nur aus Leerzeichen bestehen.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('weist ein einzelnes wiederholtes Zeichen ab', () => {
|
||||||
|
expect(passwortMaengel('xxxxxxxxxxxxxxxx')).toContain(
|
||||||
|
'Darf nicht aus einem einzigen wiederholten Zeichen bestehen.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('meldet mehrere Maengel gleichzeitig', () => {
|
||||||
|
expect(passwortMaengel('aaa').length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('passwortIstGut', () => {
|
||||||
|
it('fasst das Ergebnis zusammen', () => {
|
||||||
|
expect(passwortIstGut('richtiges pferd batterie')).toBe(true)
|
||||||
|
expect(passwortIstGut('kurz')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Regeln fuer Passwoerter. Reine Logik, damit sie in Formular und Server
|
||||||
|
* Action dieselbe Antwort gibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PASSWORT_MINDESTLAENGE = 12
|
||||||
|
export const PASSWORT_HOECHSTLAENGE = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft ein Passwort und gibt alle Verstoesse zurueck. Eine leere Liste
|
||||||
|
* heisst: in Ordnung.
|
||||||
|
*
|
||||||
|
* Bewusst keine Pflicht zu Sonderzeichen – Laenge traegt mehr zur Sicherheit
|
||||||
|
* bei als erzwungene Zeichenklassen, die zu "Passwort1!" fuehren.
|
||||||
|
*/
|
||||||
|
export function passwortMaengel(passwort: string): string[] {
|
||||||
|
const maengel: string[] = []
|
||||||
|
|
||||||
|
if (passwort.length < PASSWORT_MINDESTLAENGE) {
|
||||||
|
maengel.push(`Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`)
|
||||||
|
}
|
||||||
|
if (passwort.length > PASSWORT_HOECHSTLAENGE) {
|
||||||
|
maengel.push(`Höchstens ${PASSWORT_HOECHSTLAENGE} Zeichen.`)
|
||||||
|
}
|
||||||
|
if (passwort.trim().length === 0) {
|
||||||
|
maengel.push('Darf nicht nur aus Leerzeichen bestehen.')
|
||||||
|
}
|
||||||
|
if (/^(.)\1*$/.test(passwort) && passwort.length > 0) {
|
||||||
|
maengel.push('Darf nicht aus einem einzigen wiederholten Zeichen bestehen.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return maengel
|
||||||
|
}
|
||||||
|
|
||||||
|
export function passwortIstGut(passwort: string): boolean {
|
||||||
|
return passwortMaengel(passwort).length === 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ROLLEN_NAME, istAdmin, rolleReichtAus } from './rollen'
|
||||||
|
|
||||||
|
describe('rolleReichtAus', () => {
|
||||||
|
it('laesst jede Rolle das, was sie selbst ist', () => {
|
||||||
|
expect(rolleReichtAus('BENUTZER', 'BENUTZER')).toBe(true)
|
||||||
|
expect(rolleReichtAus('ADMIN', 'ADMIN')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst die Verwaltung auch alles Niedrigere', () => {
|
||||||
|
expect(rolleReichtAus('ADMIN', 'BENUTZER')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('laesst einen Benutzer nicht in die Verwaltung', () => {
|
||||||
|
expect(rolleReichtAus('BENUTZER', 'ADMIN')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('istAdmin', () => {
|
||||||
|
it('erkennt nur die Verwaltung', () => {
|
||||||
|
expect(istAdmin('ADMIN')).toBe(true)
|
||||||
|
expect(istAdmin('BENUTZER')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ROLLEN_NAME', () => {
|
||||||
|
it('benennt jede Rolle deutsch', () => {
|
||||||
|
expect(ROLLEN_NAME.BENUTZER).toBe('Benutzer')
|
||||||
|
expect(ROLLEN_NAME.ADMIN).toBe('Verwaltung')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Rolle } from '@/generated/prisma/enums'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rangfolge der Rollen. Reine Logik, damit Seite, Server Action und
|
||||||
|
* Oberflaeche dieselbe Antwort geben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RANG: Record<Rolle, number> = {
|
||||||
|
BENUTZER: 1,
|
||||||
|
ADMIN: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLLEN_NAME: Record<Rolle, string> = {
|
||||||
|
BENUTZER: 'Benutzer',
|
||||||
|
ADMIN: 'Verwaltung',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reicht die vorhandene Rolle fuer das Verlangte? */
|
||||||
|
export function rolleReichtAus(vorhanden: Rolle, verlangt: Rolle): boolean {
|
||||||
|
return RANG[vorhanden] >= RANG[verlangt]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istAdmin(rolle: Rolle): boolean {
|
||||||
|
return rolleReichtAus(rolle, 'ADMIN')
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
/** Klassennamen zusammenfuehren, spaetere Tailwind-Klassen gewinnen. */
|
||||||
|
export function cn(...eingaben: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(eingaben))
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { VERSION, versionAnzeige } from './version'
|
||||||
|
|
||||||
|
describe('VERSION', () => {
|
||||||
|
it('kommt aus der package.json und sieht nach Semver aus', () => {
|
||||||
|
expect(VERSION).toMatch(/^\d+\.\d+\.\d+(?:-[\w.]+)?$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('versionAnzeige', () => {
|
||||||
|
it('stellt ein v voran', () => {
|
||||||
|
expect(versionAnzeige('0.1.0')).toBe('v0.1.0')
|
||||||
|
})
|
||||||
|
})
|
||||||