Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca26b9d0ce | ||
|
|
475e544d77 | ||
|
|
6066164ce9 | ||
|
|
e273a0dcaa | ||
|
|
36523a72d5 | ||
|
|
37573962a8 | ||
|
|
1e9f7bd45a | ||
|
|
e13c335697 | ||
|
|
955e0cefbc | ||
|
|
a412963b5d |
+47
-9
@@ -4,6 +4,28 @@
|
||||
# (Die .env wird sowohl von Next.js als auch von docker compose gelesen.)
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Anwendung ────────────────────────────────────────────────
|
||||
# Anzeigename: Seitentitel, Kopfzeile und Name im Passkey-Dialog.
|
||||
APP_NAME="Nebula"
|
||||
# Kleine Zeile unter dem Namen in der Kopfzeile.
|
||||
APP_TAGLINE="Vibe Coding"
|
||||
|
||||
# Oeffentliche Adresse der Instanz – genau so, wie du sie im Browser aufrufst.
|
||||
#
|
||||
# WICHTIG fuer Passkeys: Daraus werden die WebAuthn-Relying-Party-ID (der
|
||||
# Hostname) und der erwartete Origin abgeleitet. Passt der Wert nicht zur
|
||||
# aufgerufenen Adresse, lehnt der Browser die Anmeldung ab. Passkeys brauchen
|
||||
# ausserdem einen sicheren Kontext: https:// – oder http://localhost.
|
||||
#
|
||||
# Wird die rpID spaeter geaendert (anderer Hostname), sind bestehende Passkeys
|
||||
# ungueltig und muessen neu angelegt werden. Passwoerter bleiben davon unberuehrt.
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Port innerhalb des Containers bzw. im Standalone-Betrieb
|
||||
PORT="3000"
|
||||
# Host-Port-Mapping fuer den App-Container (--profile full)
|
||||
APP_PORT="3000"
|
||||
|
||||
# ── Datenbank ────────────────────────────────────────────────
|
||||
# Standalone-Betrieb: App laeuft lokal, Postgres via "docker compose up -d db"
|
||||
# oder als nativ installierter Dienst.
|
||||
@@ -18,23 +40,39 @@ POSTGRES_PASSWORD="nebula"
|
||||
POSTGRES_DB="nebula"
|
||||
# Host-Port, unter dem die Datenbank erreichbar ist
|
||||
POSTGRES_PORT="5432"
|
||||
# Host-Port fuer Adminer (--profile tools)
|
||||
ADMINER_PORT="8080"
|
||||
|
||||
# ── Sicherheit ───────────────────────────────────────────────
|
||||
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
|
||||
# Erzeugen mit: openssl rand -base64 32
|
||||
# oder: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
# oder: npm run setup (legt die .env inkl. Schluessel an)
|
||||
# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Tokens
|
||||
# nicht mehr entschluesselbar und muessen neu hinterlegt werden.
|
||||
# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Git-Tokens
|
||||
# nicht mehr entschluesselbar und muessen neu hinterlegt werden. Anmeldungen
|
||||
# und Passkeys sind davon nicht betroffen.
|
||||
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
|
||||
|
||||
# ── App ──────────────────────────────────────────────────────
|
||||
# Port innerhalb des Containers bzw. im Standalone-Betrieb
|
||||
PORT="3000"
|
||||
# Host-Port-Mapping fuer den App-Container (--profile full)
|
||||
APP_PORT="3000"
|
||||
# Host-Port fuer Adminer (--profile tools)
|
||||
ADMINER_PORT="8080"
|
||||
# Lebensdauer einer Anmeldung in Tagen.
|
||||
SESSION_TTL_DAYS="30"
|
||||
|
||||
# ── Ausgehende Git-Zugriffe ──────────────────────────────────
|
||||
# Timeout fuer Git-API-Aufrufe in Millisekunden
|
||||
GIT_API_TIMEOUT_MS="15000"
|
||||
|
||||
# Schutz vor Server Side Request Forgery: die Basis-URL einer Git-Verbindung
|
||||
# ist eine Benutzereingabe und wird vom Server abgerufen.
|
||||
#
|
||||
# Voreinstellung: Loopback, Link-Local (169.254.x, Cloud-Metadaten), Multicast
|
||||
# und CGNAT sind gesperrt; das private LAN ist erlaubt, weil dort ueblicherweise
|
||||
# die eigene Gitea-Instanz steht.
|
||||
|
||||
# Gitea laeuft auf demselben Rechner wie Nebula? Dann auf true setzen.
|
||||
# GIT_ALLOW_LOOPBACK="false"
|
||||
|
||||
# Kein Zugriff ins private Netz (10/8, 172.16/12, 192.168/16, fc00::/7)?
|
||||
# GIT_BLOCK_PRIVATE="false"
|
||||
|
||||
# Strengster Modus: nur diese Hosts sind erlaubt, alles andere wird geblockt.
|
||||
# Eine Angabe hier hebt die beiden Schalter darueber auf.
|
||||
# GIT_ALLOWED_HOSTS="github.com,gitlab.com,git.meine-firma.de"
|
||||
|
||||
+80
-4
@@ -5,6 +5,52 @@ Für Installation und Betrieb siehe [README.md](README.md).
|
||||
|
||||
---
|
||||
|
||||
## 0. Konten und Zugang
|
||||
|
||||
### 0.1 Betriebsarten
|
||||
|
||||
Beim ersten Aufruf führt `/setup` durch die Einrichtung des Administratorkontos. Dort — und
|
||||
später jederzeit unter *Admin-Einstellungen* — wird die Betriebsart gewählt:
|
||||
|
||||
| Modus | Verhalten |
|
||||
| --- | --- |
|
||||
| **Einzelbetrieb** | Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung. |
|
||||
| **Mehrbenutzerbetrieb** | Beliebig viele Konten mit strikt getrennten Daten. Selbstregistrierung optional zuschaltbar. |
|
||||
|
||||
Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein Konto existiert —
|
||||
sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft gesperrt.
|
||||
|
||||
### 0.2 Anmeldung
|
||||
|
||||
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden
|
||||
abgelehnt. Hashing mit scrypt.
|
||||
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
|
||||
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
|
||||
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
|
||||
- Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich
|
||||
dann nicht entfernen, solange kein Passwort gesetzt ist.
|
||||
- Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten.
|
||||
- Die Fehlermeldung ist immer dieselbe und die Antwortzeit gleich lang, egal ob es den
|
||||
Benutzernamen gibt — so lässt sich nicht ausprobieren, welche Konten existieren.
|
||||
|
||||
### 0.3 Mein Konto
|
||||
|
||||
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Passkeys verwalten ·
|
||||
aktive Sitzungen einsehen (Browser, Betriebssystem, IP, letzte Aktivität) · überall
|
||||
abmelden, wobei das eigene Gerät angemeldet bleibt.
|
||||
|
||||
### 0.4 Benutzerverwaltung (Administratoren)
|
||||
|
||||
- Konten anlegen, umbenennen, Rolle wechseln, deaktivieren, löschen
|
||||
- Passwort eines Kontos neu setzen — beendet dessen Sitzungen sofort
|
||||
- Übersicht je Konto: Projektzahl, Passkeys, letzte Anmeldung, Sperrstatus
|
||||
- Selbstregistrierung an- und abschaltbar
|
||||
- Schutzregeln: Der letzte aktive Administrator kann weder herabgestuft, deaktiviert noch
|
||||
gelöscht werden; das eigene Konto lässt sich nicht löschen oder deaktivieren.
|
||||
- **Administratoren sehen keine fremden Projekte.** Sie verwalten Konten, nicht Inhalte.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projekte
|
||||
|
||||
### 1.1 Anlegen und Verwalten
|
||||
@@ -157,21 +203,49 @@ Firmen-Gitea nebeneinander.
|
||||
|
||||
| Tabelle | Inhalt |
|
||||
| --- | --- |
|
||||
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung |
|
||||
| `User` | Konten, Rolle, Passwort-Hash, Sperrzähler |
|
||||
| `Session` | Serverseitige Sitzungen (nur der Token-Hash) |
|
||||
| `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler |
|
||||
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
|
||||
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
|
||||
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer |
|
||||
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
|
||||
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token |
|
||||
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
|
||||
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
|
||||
|
||||
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus
|
||||
der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
|
||||
übernommen. Slug und Verbindungsname sind je Besitzer eindeutig — zwei Personen dürfen
|
||||
beide ein Projekt „dashboard" haben.
|
||||
|
||||
Listen wie Branches oder Commits liegen als `JSONB`, damit providerspezifische Felder
|
||||
nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
|
||||
|
||||
### 6.2 Sicherheit
|
||||
|
||||
- **Zugangsschutz in zwei Ebenen.** Die Middleware prüft nur, ob ein Sitzungscookie
|
||||
überhaupt mitkommt — im Edge-Runtime gibt es keine Datenbank. Verbindlich entschieden
|
||||
wird serverseitig in jeder Seite und jedem Endpunkt (`requireUserPage` /
|
||||
`requireUserApi`). Das ist die eigentliche Sicherheitsgrenze.
|
||||
- **Datentrennung** über eine Besitzerbedingung in jeder Abfrage. Schreibzugriffe laufen
|
||||
über `updateMany`/`deleteMany` mit `ownerId` in der `where`-Klausel, treffen fremde
|
||||
Datensätze also gar nicht erst. Fremde IDs liefern `404` statt `403`.
|
||||
- **CSRF**: Origin- bzw. `Sec-Fetch-Site`-Prüfung für alle schreibenden Methoden, dazu
|
||||
erzwungener `Content-Type: application/json`.
|
||||
- **CSP mit Per-Request-Nonce** und `strict-dynamic`, plus `frame-ancestors 'none'`,
|
||||
`object-src 'none'`, `base-uri 'none'` und die üblichen Schutz-Header.
|
||||
- **SSRF-Schutz** vor jedem ausgehenden Git-Aufruf: Hostname wird aufgelöst, jede
|
||||
Antwortadresse geprüft (schützt auch gegen DNS-Rebinding über einen zweiten A-Record),
|
||||
Redirect-Ziele werden erneut geprüft. Konfigurierbar über `GIT_ALLOW_LOOPBACK`,
|
||||
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
|
||||
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
|
||||
WebAuthn-Optionen.
|
||||
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
|
||||
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
|
||||
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
|
||||
ohne `tokenCipher`.
|
||||
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI.
|
||||
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI, Body auf
|
||||
256 KB begrenzt.
|
||||
- Prisma arbeitet ausschließlich mit parametrisierten Abfragen.
|
||||
|
||||
### 6.3 Betrieb
|
||||
@@ -204,4 +278,6 @@ Bewusst nicht umgesetzt, aber vorgemerkt:
|
||||
- Volltextsuche über Notizen hinweg
|
||||
- Export eines Projekts als Markdown
|
||||
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
|
||||
- Mehrbenutzerbetrieb mit Login
|
||||
- Passwort-Zurücksetzen per E-Mail (aktuell setzt ein Administrator das Passwort neu)
|
||||
- Zwei-Faktor per TOTP als Alternative zum Passkey
|
||||
- Geteilte Projekte zwischen Konten (heute ist die Trennung absichtlich hart)
|
||||
|
||||
@@ -22,6 +22,11 @@ Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beli
|
||||
Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab**
|
||||
oder eine selbst gehostete **Gitea**-Instanz.
|
||||
|
||||
Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula
|
||||
kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in
|
||||
dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in
|
||||
den Admin-Einstellungen.
|
||||
|
||||
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
||||
Access Tokens werden verschlüsselt abgelegt.
|
||||
|
||||
@@ -106,21 +111,55 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
||||
|
||||
| Variable | Bedeutung | Default |
|
||||
| --- | --- | --- |
|
||||
| `APP_NAME` | Anzeigename: Seitentitel, Kopfzeile, Passkey-Dialog | `Nebula` |
|
||||
| `APP_TAGLINE` | Kleine Zeile unter dem Namen | `Vibe Coding` |
|
||||
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
|
||||
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
|
||||
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
|
||||
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
|
||||
| `PORT` | Port der Anwendung | `3000` |
|
||||
| `APP_PORT` | Host-Port-Mapping des App-Containers | `3000` |
|
||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
||||
| `POSTGRES_PORT` | Host-Port der Datenbank | `5432` |
|
||||
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `8080` |
|
||||
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
|
||||
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
|
||||
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
|
||||
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
|
||||
|
||||
`APP_NAME`, `APP_TAGLINE` und `APP_URL` werden **zur Laufzeit** gelesen. Dasselbe
|
||||
Docker-Image lässt sich also ohne Neubau umbenennen oder unter einer anderen Adresse
|
||||
betreiben — Container neu starten genügt.
|
||||
|
||||
> **`APP_SECRET` nachträglich ändern** macht bereits gespeicherte Git-Tokens unlesbar.
|
||||
> Sie müssen dann in den Verbindungen neu hinterlegt werden — die restlichen Daten bleiben
|
||||
> unberührt.
|
||||
> Sie müssen dann in den Verbindungen neu hinterlegt werden — Konten, Passwörter und
|
||||
> Passkeys bleiben unberührt.
|
||||
|
||||
> **`APP_URL` nachträglich ändern** (anderer Hostname) macht bestehende **Passkeys**
|
||||
> ungültig, weil sie an die RP-ID gebunden sind. Passwörter funktionieren weiter.
|
||||
|
||||
---
|
||||
|
||||
## Erste Anmeldung
|
||||
|
||||
Beim ersten Aufruf landest du auf `/setup`. Dort legst du das Administratorkonto an und
|
||||
wählst die Betriebsart:
|
||||
|
||||
- **Einzelbetrieb** — genau ein Konto, keine Registrierung, keine Benutzerliste.
|
||||
- **Mehrbenutzerbetrieb** — beliebig viele Konten. Projekte, Notizen und Git-Verbindungen
|
||||
sind pro Konto strikt getrennt; auch ein Administrator sieht fremde Projekte nicht.
|
||||
|
||||
Nach der Einrichtung ist `/setup` gesperrt. Bereits vorhandene Projekte ohne Besitzer
|
||||
(etwa aus den Seed-Daten) werden dem neuen Konto zugeordnet.
|
||||
|
||||
**Passkey einrichten:** unter *Mein Konto → Passkeys → Passkey hinzufügen*. Danach genügt
|
||||
auf der Anmeldeseite ein Klick auf „Mit Passkey anmelden" — ohne Benutzernamen, weil
|
||||
Nebula auffindbare Credentials nutzt.
|
||||
|
||||
> Passkeys brauchen einen sicheren Kontext. Sie funktionieren über `https://` und über
|
||||
> `http://localhost`, **nicht** über eine LAN-IP ohne TLS. `APP_URL` muss dabei exakt der
|
||||
> Adresse entsprechen, unter der du die App aufrufst.
|
||||
|
||||
## Git-Verbindung einrichten
|
||||
|
||||
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen:
|
||||
@@ -197,9 +236,23 @@ nebula/
|
||||
|
||||
Alle Endpunkte liefern und erwarten JSON.
|
||||
|
||||
Bis auf `/api/health` und `/api/auth/*` erfordern alle Endpunkte eine Anmeldung und liefern
|
||||
ausschließlich Daten des angemeldeten Kontos.
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
|
||||
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
|
||||
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
|
||||
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
|
||||
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
|
||||
| `PATCH` | `/api/account/profile` | Eigenes Profil |
|
||||
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
||||
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
||||
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
|
||||
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart und Registrierung (Admin) |
|
||||
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
|
||||
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
|
||||
@@ -220,16 +273,36 @@ curl -X POST http://localhost:3000/api/projects \
|
||||
|
||||
---
|
||||
|
||||
## Sicherheitshinweise
|
||||
## Sicherheit
|
||||
|
||||
| Bereich | Umsetzung |
|
||||
| --- | --- |
|
||||
| **Anmeldung** | Passwort-Hashing mit **scrypt** (N=32768, r=8, p=1), serverseitige Sitzungen — in der Datenbank liegt nur der SHA-256-Hash des Cookie-Tokens |
|
||||
| **Passkeys** | WebAuthn über SimpleWebAuthn, Challenges einmalig verwendbar und in der DB abgelegt, Signaturzähler wird fortgeschrieben |
|
||||
| **Cookies** | `HttpOnly`, `SameSite=Lax`, `Secure` automatisch bei https |
|
||||
| **CSRF** | Jede schreibende Anfrage muss vom eigenen Origin kommen (`Sec-Fetch-Site` / `Origin`); zusätzlich wird `Content-Type: application/json` erzwungen, was den Formular-Trick ohne Preflight ausschließt |
|
||||
| **CSP** | Strikte Policy mit Per-Request-**Nonce** und `strict-dynamic`, dazu `frame-ancestors 'none'`, `object-src 'none'`, `base-uri 'none'` |
|
||||
| **Weitere Header** | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` |
|
||||
| **SSRF** | Die Basis-URL einer Git-Verbindung ist Benutzereingabe. Vor jedem ausgehenden Aufruf werden Hostname und **alle** aufgelösten IPs geprüft (auch nach Redirects); Loopback, Link-Local samt Cloud-Metadaten, Multicast und CGNAT sind gesperrt |
|
||||
| **Datentrennung** | Jede Abfrage filtert auf den Besitzer. Fremde IDs liefern `404`, nicht `403` — die Antwort verrät also nicht einmal, dass es den Datensatz gibt |
|
||||
| **Rate-Limiting** | Anmeldung pro IP und Konto, ausgehende Git-Aufrufe, WebAuthn-Optionen; Konto sperrt sich nach 8 Fehlversuchen für 15 Minuten |
|
||||
| **Eingaben** | Zod-Schemas an jeder API-Grenze, Body auf 256 KB begrenzt |
|
||||
| **Git-Tokens** | AES-256-GCM, Schlüssel per scrypt aus `APP_SECRET`. Werden nie an den Browser ausgeliefert — die API zeigt nur `ghp_…9f3a` |
|
||||
| **Sitzungsende** | Deaktivieren, Löschen oder ein neues Passwort beenden sofort alle Sitzungen des Kontos |
|
||||
|
||||
Grenzen, die du kennen solltest:
|
||||
|
||||
- Nebula hat **keine Benutzerverwaltung** und ist für den Betrieb im eigenen Netz gedacht.
|
||||
Vor einer Veröffentlichung im Internet unbedingt einen Reverse Proxy mit Authentifizierung
|
||||
davorschalten.
|
||||
- Access Tokens werden mit **AES-256-GCM** verschlüsselt gespeichert, aus `APP_SECRET` per
|
||||
scrypt abgeleitet. Sie werden ausschließlich serverseitig entschlüsselt und nie an den
|
||||
Browser ausgeliefert — die API zeigt nur einen Hinweis wie `ghp_…9f3a`.
|
||||
- Ist `APP_SECRET` nicht gesetzt, warnt die App beim Start und nutzt einen unsicheren
|
||||
Entwicklungsschlüssel.
|
||||
- Ohne `Origin`- **und** `Sec-Fetch-Site`-Header wird eine schreibende Anfrage
|
||||
durchgelassen. Das betrifft nur Nicht-Browser-Clients (curl, Skripte) und ist bewusst so,
|
||||
damit die API skriptbar bleibt — ein CSRF-Vektor ist es nicht, weil jeder Browser
|
||||
mindestens einen der beiden Header sendet.
|
||||
- Das Rate-Limiting liegt im Arbeitsspeicher und wird beim Neustart zurückgesetzt.
|
||||
- Es gibt **kein** HSTS und keine TLS-Terminierung. Für den Betrieb im Internet gehört ein
|
||||
Reverse Proxy mit gültigem Zertifikat davor — der ist ohnehin nötig, damit Passkeys
|
||||
funktionieren.
|
||||
- Passwort-Zurücksetzen per E-Mail gibt es nicht; ein Administrator setzt Passwörter neu.
|
||||
|
||||
---
|
||||
|
||||
@@ -244,6 +317,27 @@ curl -X POST http://localhost:3000/api/projects \
|
||||
| Sync meldet `403 Rate-Limit` | Ohne Token ist das Limit sehr niedrig — Token hinterlegen |
|
||||
| Tokens plötzlich ungültig | `APP_SECRET` wurde geändert — Tokens neu eintragen |
|
||||
| Port 3000 belegt | `APP_PORT` in der `.env` ändern (Docker) bzw. `npm run dev -- -p 3001` |
|
||||
| `package.json#prisma is deprecated` | Harmlose Warnung von Prisma 6.19. Die Umstellung auf `prisma.config.ts` steht aus, weil dann die `.env` nicht mehr automatisch geladen wird — vor Prisma 7 zu erledigen |
|
||||
| Passkey-Knopf fehlt / „kein sicherer Kontext" | `APP_URL` zeigt weder auf `https://` noch auf `http://localhost` |
|
||||
| Passkey wird nicht akzeptiert | `APP_URL` weicht von der aufgerufenen Adresse ab, oder der Hostname wurde geändert (RP-ID) |
|
||||
| Nach `docker compose down -v` ist alles weg | Das Volume enthielt Konten und Projekte — `-v` löscht es |
|
||||
| Kein Zugriff auf die eigene Gitea im LAN | Läuft sie auf demselben Rechner? Dann `GIT_ALLOW_LOOPBACK=true` setzen |
|
||||
|
||||
---
|
||||
|
||||
## Getestet mit
|
||||
|
||||
Stand 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei
|
||||
Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz).
|
||||
|
||||
| | Version |
|
||||
| --- | --- |
|
||||
| Node.js | 26.5.0 |
|
||||
| npm | 11.17.0 |
|
||||
| Next.js | 15.5.21 |
|
||||
| Prisma | 6.19.3 |
|
||||
| PostgreSQL | 17 (Alpine) |
|
||||
| Docker / Compose | 29.6.2 / v5.3.1 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -52,7 +52,16 @@ services:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-nebula}:${POSTGRES_PASSWORD:-nebula}@db:5432/${POSTGRES_DB:-nebula}?schema=public
|
||||
# Unbedingt in der .env ueberschreiben – verschluesselt die Git-Tokens.
|
||||
APP_SECRET: ${APP_SECRET:-nebula-dev-secret-bitte-in-der-env-aendern}
|
||||
# Name und oeffentliche Adresse. APP_URL bestimmt die WebAuthn-rpID –
|
||||
# passt sie nicht zur aufgerufenen Adresse, scheitern Passkeys.
|
||||
APP_NAME: ${APP_NAME:-Nebula}
|
||||
APP_TAGLINE: ${APP_TAGLINE:-Vibe Coding}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
|
||||
GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000}
|
||||
GIT_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false}
|
||||
GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false}
|
||||
GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-}
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
HOSTNAME: 0.0.0.0
|
||||
|
||||
Generated
+263
@@ -10,6 +10,8 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "^15.3.3",
|
||||
"react": "^19.1.0",
|
||||
@@ -495,6 +497,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
"version": "1.1.28",
|
||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -1059,6 +1067,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@levischuck/tiny-cbor": {
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
|
||||
@@ -1205,6 +1219,174 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-android": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
|
||||
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
|
||||
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
|
||||
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
|
||||
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
|
||||
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-rsa": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
|
||||
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
|
||||
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.8.0",
|
||||
"@peculiar/asn1-pfx": "^2.8.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
|
||||
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
|
||||
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
|
||||
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
|
||||
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.8.0",
|
||||
"@peculiar/asn1-x509": "^2.8.0",
|
||||
"asn1js": "^3.0.10",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||
@@ -1290,6 +1472,31 @@
|
||||
"@prisma/debug": "6.19.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/browser": {
|
||||
"version": "13.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
|
||||
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "13.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
|
||||
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
"@levischuck/tiny-cbor": "^0.2.2",
|
||||
"@peculiar/asn1-android": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.1",
|
||||
"@peculiar/asn1-rsa": "^2.6.1",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.1",
|
||||
"@peculiar/x509": "^1.14.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -1619,6 +1826,20 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
@@ -2461,6 +2682,24 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@@ -2507,6 +2746,12 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -2659,6 +2904,24 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "^15.3.3",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "GitConnection_name_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Project_slug_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT,
|
||||
"email" TEXT,
|
||||
"passwordHash" TEXT,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lockedUntil" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userAgent" TEXT,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Credential" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"credentialId" TEXT NOT NULL,
|
||||
"publicKey" BYTEA NOT NULL,
|
||||
"counter" INTEGER NOT NULL DEFAULT 0,
|
||||
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"deviceType" TEXT,
|
||||
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
||||
"name" TEXT,
|
||||
"lastUsedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WebAuthnChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"challenge" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppSettings" (
|
||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
||||
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
|
||||
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+124
-2
@@ -33,6 +33,113 @@ enum GitProvider {
|
||||
GITEA
|
||||
}
|
||||
|
||||
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
|
||||
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
|
||||
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
|
||||
enum AppMode {
|
||||
SINGLE_USER
|
||||
MULTI_USER
|
||||
}
|
||||
|
||||
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
|
||||
/// ausschliesslich per Passkey nutzbar sein darf.
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
|
||||
/// gross-/kleinschreibungsunabhaengig funktioniert.
|
||||
username String @unique
|
||||
displayName String?
|
||||
email String? @unique
|
||||
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
|
||||
passwordHash String?
|
||||
role UserRole @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
lastLoginAt DateTime?
|
||||
/// Zaehler und Sperre gegen Passwortraten.
|
||||
failedLoginCount Int @default(0)
|
||||
lockedUntil DateTime?
|
||||
|
||||
sessions Session[]
|
||||
credentials Credential[]
|
||||
projects Project[]
|
||||
connections GitConnection[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
|
||||
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
userAgent String?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Ein registrierter Passkey (WebAuthn-Credential).
|
||||
model Credential {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// base64url-kodierte Credential-ID des Authenticators.
|
||||
credentialId String @unique
|
||||
publicKey Bytes
|
||||
counter Int @default(0)
|
||||
transports String[] @default([])
|
||||
deviceType String?
|
||||
backedUp Boolean @default(false)
|
||||
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
|
||||
name String?
|
||||
lastUsedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
|
||||
/// kein zweites Mal verwendet werden kann.
|
||||
model WebAuthnChallenge {
|
||||
id String @id @default(cuid())
|
||||
challenge String @unique
|
||||
/// null bei der Anmeldung ueber auffindbare Passkeys.
|
||||
userId String?
|
||||
/// "registration" oder "authentication"
|
||||
purpose String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
|
||||
model AppSettings {
|
||||
id String @id @default("singleton")
|
||||
mode AppMode @default(SINGLE_USER)
|
||||
/// Nur im Mehrbenutzerbetrieb wirksam.
|
||||
allowRegistration Boolean @default(false)
|
||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||
rpName String @default("Nebula")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||
model GitConnection {
|
||||
id String @id @default(cuid())
|
||||
@@ -53,20 +160,28 @@ model GitConnection {
|
||||
lastCheckOk Boolean?
|
||||
lastCheckMessage String?
|
||||
|
||||
/// Besitzer. Nullable, damit Bestandsdaten aus der Zeit vor der
|
||||
/// Benutzerverwaltung erhalten bleiben; beim Einrichten des ersten
|
||||
/// Kontos werden sie diesem zugeordnet.
|
||||
ownerId String?
|
||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
projects Project[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([name])
|
||||
@@unique([ownerId, name])
|
||||
@@index([provider])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// Ein Vibe-Coding-Projekt – von der reinen Idee bis zum fertigen Repo.
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
/// Pro Besitzer eindeutig – siehe @@unique unten.
|
||||
slug String
|
||||
/// Einzeiler fuer die Karte im Dashboard.
|
||||
summary String?
|
||||
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
|
||||
@@ -93,15 +208,22 @@ model Project {
|
||||
lastSyncedAt DateTime?
|
||||
syncError String?
|
||||
|
||||
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||
ownerId String?
|
||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
notes Note[]
|
||||
snapshot RepoSnapshot?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([ownerId, slug])
|
||||
@@index([status])
|
||||
@@index([connectionId])
|
||||
@@index([updatedAt])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
/// Freie Notizen zu einem Projekt.
|
||||
|
||||
+5
-2
@@ -86,9 +86,12 @@ async function main() {
|
||||
},
|
||||
];
|
||||
|
||||
// Der Slug ist seit der Benutzerverwaltung nur je Besitzer eindeutig.
|
||||
// Die Seed-Projekte entstehen ohne Besitzer und werden beim Einrichten
|
||||
// des ersten Kontos uebernommen.
|
||||
for (const sample of samples) {
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { slug: sample.slug },
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { slug: sample.slug, ownerId: null },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(` · uebersprungen (existiert): ${sample.slug}`);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return { title: "Mein Konto" };
|
||||
}
|
||||
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUserPage();
|
||||
|
||||
const [passkeys, sessions] = await Promise.all([
|
||||
prisma.credential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceType: true,
|
||||
backedUp: true,
|
||||
createdAt: true,
|
||||
lastUsedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
prisma.session.findMany({
|
||||
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
||||
select: {
|
||||
id: true,
|
||||
userAgent: true,
|
||||
ip: true,
|
||||
createdAt: true,
|
||||
lastSeenAt: true,
|
||||
},
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data: AccountData = {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
hasPassword: user.hasPassword,
|
||||
passkeys,
|
||||
sessions,
|
||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6 pt-4">
|
||||
<header className="fade-up">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Profil, Anmeldemittel und aktive Sitzungen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AccountManager data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AdminManager, type AdminData } from "@/components/admin/AdminManager";
|
||||
import { requireAdminPage } from "@/lib/auth/guard";
|
||||
import { getAppSettings } from "@/lib/auth/settings";
|
||||
import { userSafeSelect } from "@/lib/auth/users";
|
||||
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return { title: "Admin-Einstellungen" };
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdminPage();
|
||||
|
||||
const [settings, users] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.findMany({
|
||||
select: userSafeSelect,
|
||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||
}),
|
||||
]);
|
||||
|
||||
const data: AdminData = {
|
||||
mode: settings.mode,
|
||||
allowRegistration: settings.allowRegistration,
|
||||
users,
|
||||
currentUserId: admin.id,
|
||||
appUrl: APP_URL,
|
||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 pt-4">
|
||||
<header className="fade-up">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
|
||||
Administration
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
|
||||
Admin-Einstellungen
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Betriebsart, Registrierung und Benutzerkonten.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AdminManager data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Umbenennen – nur der eigene Passkey. */
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = passkeyNameSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
|
||||
// gar nicht erst getroffen werden.
|
||||
const result = await prisma.credential.updateMany({
|
||||
where: { id, userId: auth.user.id },
|
||||
data: { name: parsed.data.name ?? null },
|
||||
});
|
||||
|
||||
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: auth.user.id },
|
||||
select: { passwordHash: true, _count: { select: { credentials: true } } },
|
||||
});
|
||||
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
||||
|
||||
// Nicht das letzte Anmeldemittel entfernen.
|
||||
if (!user.passwordHash && user._count.credentials <= 1) {
|
||||
return jsonError(
|
||||
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
|
||||
"Bitte zuerst ein Passwort vergeben.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
|
||||
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { changePasswordSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Eigenes Passwort setzen oder aendern.
|
||||
*
|
||||
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
|
||||
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
|
||||
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = changePasswordSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
|
||||
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
||||
|
||||
if (user.passwordHash) {
|
||||
const current = parsed.data.currentPassword ?? "";
|
||||
const valid = await verifyPassword(current, user.passwordHash);
|
||||
if (!valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Das aktuelle Passwort stimmt nicht.",
|
||||
fields: { currentPassword: "Falsch" },
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const weak = validatePasswordStrength(parsed.data.newPassword);
|
||||
if (weak) {
|
||||
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
|
||||
});
|
||||
|
||||
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
|
||||
// ausstellen, damit der Wechsel nicht abmeldet.
|
||||
await destroyAllSessions(user.id);
|
||||
const session = await createSession(user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip: clientIp(request),
|
||||
});
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = profileUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { displayName, email } = parsed.data;
|
||||
|
||||
if (email) {
|
||||
const taken = await prisma.user.findFirst({
|
||||
where: { email, NOT: { id: auth.user.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: auth.user.id },
|
||||
data: {
|
||||
...(displayName !== undefined ? { displayName } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
},
|
||||
select: { id: true, username: true, displayName: true, email: true, role: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientIp } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Uebersicht der eigenen aktiven Sitzungen. */
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
|
||||
select: {
|
||||
id: true,
|
||||
userAgent: true,
|
||||
ip: true,
|
||||
createdAt: true,
|
||||
lastSeenAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
orderBy: { lastSeenAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ sessions });
|
||||
}
|
||||
|
||||
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
|
||||
export async function DELETE(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
await destroyAllSessions(auth.user.id);
|
||||
const session = await createSession(auth.user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip: clientIp(request),
|
||||
});
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { getAppSettings } from "@/lib/auth/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
|
||||
return NextResponse.json({ settings, userCount });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = adminSettingsSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { mode, allowRegistration } = parsed.data;
|
||||
|
||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
||||
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
||||
if (mode === "SINGLE_USER") {
|
||||
const userCount = await prisma.user.count();
|
||||
if (userCount > 1) {
|
||||
return jsonError(
|
||||
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
|
||||
"Bitte zuerst die uebrigen Konten entfernen.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
},
|
||||
update: {
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { userSafeSelect } from "@/lib/auth/users";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
|
||||
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true },
|
||||
});
|
||||
if (target?.role !== "ADMIN") return false;
|
||||
|
||||
const activeAdmins = await prisma.user.count({
|
||||
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
|
||||
});
|
||||
return activeAdmins === 0;
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = adminUserUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return jsonError("Konto nicht gefunden.", 404);
|
||||
|
||||
const { displayName, email, role, isActive, password } = parsed.data;
|
||||
|
||||
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
|
||||
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
|
||||
return jsonError(
|
||||
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
const taken = await prisma.user.findFirst({
|
||||
where: { email, NOT: { id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const weak = validatePasswordStrength(password);
|
||||
if (weak) {
|
||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(displayName !== undefined ? { displayName } : {}),
|
||||
...(email !== undefined ? { email } : {}),
|
||||
...(role ? { role } : {}),
|
||||
...(isActive !== undefined ? { isActive } : {}),
|
||||
...(password ? { passwordHash: await hashPassword(password) } : {}),
|
||||
// Ein Eingriff des Administrators hebt eine Sperre auf.
|
||||
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
|
||||
},
|
||||
select: userSafeSelect,
|
||||
});
|
||||
|
||||
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
|
||||
if (isActive === false || password) {
|
||||
await destroyAllSessions(id);
|
||||
}
|
||||
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
if (id === auth.user.id) {
|
||||
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
|
||||
}
|
||||
if (await wouldRemoveLastAdmin(id)) {
|
||||
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
|
||||
// onDelete: Cascade daran und verschwinden mit.
|
||||
await prisma.user.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return jsonError("Konto nicht gefunden.", 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { userSafeSelect } from "@/lib/auth/users";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: userSafeSelect,
|
||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json({ users });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const state = await getInstanceState();
|
||||
if (!state.multiUser) {
|
||||
return jsonError(
|
||||
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
|
||||
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = adminUserCreateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { username, displayName, email, password, role } = parsed.data;
|
||||
|
||||
const weak = validatePasswordStrength(password);
|
||||
if (weak) {
|
||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||
}
|
||||
|
||||
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
||||
if (taken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||
if (emailTaken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: displayName ?? null,
|
||||
email: email ?? null,
|
||||
passwordHash: await hashPassword(password),
|
||||
role,
|
||||
},
|
||||
select: userSafeSelect,
|
||||
});
|
||||
|
||||
return NextResponse.json({ user }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
import { loginSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
|
||||
const GENERIC = "Benutzername oder Passwort ist falsch.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = clientIp(request);
|
||||
|
||||
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = loginSchema.safeParse(body.data);
|
||||
if (!parsed.success) return jsonError(GENERIC, 401);
|
||||
|
||||
const { username, password } = parsed.data;
|
||||
|
||||
const byUser = rateLimit(
|
||||
`login:user:${username}`,
|
||||
LIMITS.loginUser.limit,
|
||||
LIMITS.loginUser.windowMs,
|
||||
);
|
||||
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
|
||||
if (!user) {
|
||||
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
|
||||
await fakeVerifyDelay();
|
||||
return jsonError(GENERIC, 401);
|
||||
}
|
||||
|
||||
const locked = lockRemainingSeconds(user.lockedUntil);
|
||||
if (locked > 0) {
|
||||
return jsonError(
|
||||
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
|
||||
423,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
await fakeVerifyDelay();
|
||||
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
await fakeVerifyDelay();
|
||||
return jsonError(
|
||||
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
await noteFailedLogin(user.id);
|
||||
return jsonError(GENERIC, 401);
|
||||
}
|
||||
|
||||
await noteSuccessfulLogin(user.id);
|
||||
resetRateLimit(`login:user:${username}`);
|
||||
resetRateLimit(`login:ip:${ip}`);
|
||||
|
||||
const session = await createSession(user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
});
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
await destroyCurrentSession();
|
||||
const response = NextResponse.json({ ok: true });
|
||||
clearSessionCookie(response);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der
|
||||
* Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht
|
||||
* noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const limited = rateLimit(
|
||||
`webauthn:auth:${clientIp(request)}`,
|
||||
LIMITS.webauthn.limit,
|
||||
LIMITS.webauthn.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
try {
|
||||
const options = await buildAuthenticationOptions();
|
||||
return NextResponse.json({ options });
|
||||
} catch (error) {
|
||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { noteSuccessfulLogin } from "@/lib/auth/users";
|
||||
import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Payload {
|
||||
response?: AuthenticationResponseJSON;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = clientIp(request);
|
||||
const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody<Payload>(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const assertion = body.data?.response;
|
||||
if (!assertion || typeof assertion !== "object") {
|
||||
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await confirmAuthentication(assertion);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: result.userId } });
|
||||
if (!user || !user.isActive) {
|
||||
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||
}
|
||||
|
||||
await noteSuccessfulLogin(user.id);
|
||||
|
||||
const session = await createSession(user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
});
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||
return jsonError(error instanceof Error ? error.message : String(error), 401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Liefert die Optionen fuer navigator.credentials.create(). */
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`webauthn:reg:${clientIp(request)}`,
|
||||
LIMITS.webauthn.limit,
|
||||
LIMITS.webauthn.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
try {
|
||||
const options = await buildRegistrationOptions({
|
||||
id: auth.user.id,
|
||||
username: auth.user.username,
|
||||
displayName: auth.user.displayName,
|
||||
});
|
||||
return NextResponse.json({ options });
|
||||
} catch (error) {
|
||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Payload {
|
||||
response?: RegistrationResponseJSON;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody<Payload>(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const attestation = body.data?.response;
|
||||
if (!attestation || typeof attestation !== "object") {
|
||||
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await confirmRegistration(
|
||||
auth.user.id,
|
||||
attestation,
|
||||
typeof body.data?.name === "string" ? body.data.name : null,
|
||||
);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
deviceType: credential.deviceType,
|
||||
backedUp: credential.backedUp,
|
||||
createdAt: credential.createdAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
||||
return jsonError(error instanceof Error ? error.message : String(error), 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
import { formatZodError, registerSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb
|
||||
* laeuft und ein Administrator die Registrierung freigeschaltet hat.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const ip = clientIp(request);
|
||||
const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const state = await getInstanceState();
|
||||
if (state.needsSetup) {
|
||||
return jsonError("Die Instanz ist noch nicht eingerichtet.", 409);
|
||||
}
|
||||
if (!state.registrationOpen) {
|
||||
return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = registerSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { username, displayName, email, password } = parsed.data;
|
||||
|
||||
const weak = validatePasswordStrength(password);
|
||||
if (weak) {
|
||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||
}
|
||||
|
||||
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
||||
if (taken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
||||
if (emailTaken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: displayName ?? null,
|
||||
email: email ?? null,
|
||||
passwordHash: await hashPassword(password),
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
|
||||
const session = await createSession(user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
});
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */
|
||||
export async function GET() {
|
||||
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
|
||||
|
||||
return NextResponse.json({
|
||||
user,
|
||||
needsSetup: state.needsSetup,
|
||||
registrationOpen: state.registrationOpen,
|
||||
multiUser: state.multiUser,
|
||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { claimOrphanedData } from "@/lib/auth/users";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, setupSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Erstmalige Einrichtung: legt das Administratorkonto an.
|
||||
*
|
||||
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
|
||||
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
|
||||
* erschleichen.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const existing = await prisma.user.count();
|
||||
if (existing > 0) {
|
||||
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
|
||||
}
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = setupSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { username, displayName, email, password, mode } = parsed.data;
|
||||
|
||||
const weak = validatePasswordStrength(password);
|
||||
if (weak) {
|
||||
return NextResponse.json(
|
||||
{ error: weak, fields: { password: weak } },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: displayName ?? null,
|
||||
email: email ?? null,
|
||||
passwordHash: await hashPassword(password),
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", mode },
|
||||
update: { mode },
|
||||
});
|
||||
|
||||
const claimed = await claimOrphanedData(user.id);
|
||||
|
||||
const session = await createSession(user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip: clientIp(request),
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
claimed,
|
||||
});
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
return response;
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientForConnection } from "@/lib/git";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Testet Erreichbarkeit und Token einer Verbindung und merkt sich das Ergebnis. */
|
||||
export async function POST(_request: Request, { params }: Params) {
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||
LIMITS.outbound.limit,
|
||||
LIMITS.outbound.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const connection = await prisma.gitConnection.findUnique({ where: { id } });
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
const connection = await prisma.gitConnection.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
});
|
||||
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
|
||||
|
||||
const result = await clientForConnection(connection).check();
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clientForConnection } from "@/lib/git";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -8,13 +11,23 @@ type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Listet Repositories, auf die der hinterlegte Token Zugriff hat. */
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||
LIMITS.outbound.limit,
|
||||
LIMITS.outbound.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const { id } = await params;
|
||||
const query = new URL(request.url).searchParams.get("q") ?? undefined;
|
||||
|
||||
const connection = await prisma.gitConnection.findUnique({ where: { id } });
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
const connection = await prisma.gitConnection.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
});
|
||||
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
|
||||
|
||||
try {
|
||||
const repos = await clientForConnection(connection).listRepos(query);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { normalizeBaseUrl } from "@/lib/git/http";
|
||||
import { connectionSafeSelect } from "@/lib/projects";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||
import { connectionUpdateSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,16 +18,21 @@ const safeSelect = {
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const existing = await prisma.gitConnection.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) return jsonError("Verbindung nicht gefunden", 404);
|
||||
|
||||
const parsed = connectionUpdateSchema.safeParse(body);
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = connectionUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
@@ -36,9 +44,24 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) update.name = data.name;
|
||||
if (data.provider !== undefined) update.provider = data.provider;
|
||||
if (data.baseUrl !== undefined) update.baseUrl = normalizeBaseUrl(data.baseUrl);
|
||||
if (data.username !== undefined) update.username = data.username;
|
||||
|
||||
if (data.baseUrl !== undefined) {
|
||||
const normalized = normalizeBaseUrl(data.baseUrl);
|
||||
try {
|
||||
await assertAllowedUrl(normalized);
|
||||
} catch (error) {
|
||||
if (error instanceof BlockedHostError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, fields: { baseUrl: error.message } },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
update.baseUrl = normalized;
|
||||
}
|
||||
|
||||
// token === null -> Token entfernen
|
||||
// token === "" -> vom Schema zu null normalisiert
|
||||
// token undefined -> unveraendert lassen
|
||||
@@ -52,25 +75,38 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await prisma.gitConnection.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
select: safeSelect,
|
||||
if (data.name !== undefined) {
|
||||
const duplicate = await prisma.gitConnection.findFirst({
|
||||
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
|
||||
select: { id: true },
|
||||
});
|
||||
return NextResponse.json({ connection });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden oder Name vergeben" }, { status: 404 });
|
||||
if (duplicate) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const connection = await prisma.gitConnection.update({
|
||||
where: { id },
|
||||
data: update,
|
||||
select: safeSelect,
|
||||
});
|
||||
return NextResponse.json({ connection });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
|
||||
await prisma.gitConnection.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
|
||||
const result = await prisma.gitConnection.deleteMany({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
});
|
||||
if (result.count === 0) return jsonError("Verbindung nicht gefunden", 404);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { encryptSecret, tokenHint } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { normalizeBaseUrl } from "@/lib/git/http";
|
||||
import { connectionSafeSelect } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||
import { connectionCreateSchema, formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -14,7 +18,11 @@ const safeSelect = {
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const connections = await prisma.gitConnection.findMany({
|
||||
where: { ownerId: auth.user.id },
|
||||
select: safeSelect,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
@@ -22,14 +30,20 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const parsed = connectionCreateSchema.safeParse(body);
|
||||
const limited = rateLimit(
|
||||
`mutate:${clientIp(request)}`,
|
||||
LIMITS.mutation.limit,
|
||||
LIMITS.mutation.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = connectionCreateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
@@ -39,16 +53,37 @@ export async function POST(request: Request) {
|
||||
|
||||
const { name, provider, baseUrl, username, token } = parsed.data;
|
||||
|
||||
const duplicate = await prisma.gitConnection.findUnique({ where: { name } });
|
||||
// Die Basis-URL wird spaeter serverseitig abgerufen – hier pruefen, damit
|
||||
// sie nicht auf interne Dienste oder Metadaten-Endpunkte zeigt.
|
||||
try {
|
||||
await assertAllowedUrl(normalizeBaseUrl(baseUrl));
|
||||
} catch (error) {
|
||||
if (error instanceof BlockedHostError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, fields: { baseUrl: error.message } },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duplicate = await prisma.gitConnection.findFirst({
|
||||
where: { name, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicate) {
|
||||
return NextResponse.json(
|
||||
{ error: "Es gibt bereits eine Verbindung mit diesem Namen.", fields: { name: "Name bereits vergeben" } },
|
||||
{
|
||||
error: "Es gibt bereits eine Verbindung mit diesem Namen.",
|
||||
fields: { name: "Name bereits vergeben" },
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const connection = await prisma.gitConnection.create({
|
||||
data: {
|
||||
ownerId: auth.user.id,
|
||||
name,
|
||||
provider,
|
||||
baseUrl: normalizeBaseUrl(baseUrl),
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, noteUpdateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Eine Notiz gehoert dem, dem das zugehoerige Projekt gehoert. */
|
||||
async function ownedNote(noteId: string, ownerId: string) {
|
||||
return prisma.note.findFirst({
|
||||
where: { id: noteId, project: { ownerId } },
|
||||
select: { id: true, projectId: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const note = await ownedNote(id, auth.user.id);
|
||||
if (!note) return jsonError("Notiz nicht gefunden", 404);
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = noteUpdateSchema.safeParse(body);
|
||||
const parsed = noteUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
@@ -24,27 +35,28 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
|
||||
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ note });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
const updated = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
|
||||
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ note: updated });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
await prisma.note.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = await prisma.note.deleteMany({
|
||||
where: { id, project: { ownerId: auth.user.id } },
|
||||
});
|
||||
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, noteCreateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Prueft in einem Zug Existenz und Besitz des Projekts. */
|
||||
async function ownedProject(projectId: string, ownerId: string) {
|
||||
return prisma.project.findFirst({
|
||||
where: { id: projectId, ownerId },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
if (!(await ownedProject(id, auth.user.id))) {
|
||||
return jsonError("Projekt nicht gefunden", 404);
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { projectId: id },
|
||||
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||||
@@ -16,21 +33,18 @@ export async function GET(_request: Request, { params }: Params) {
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const project = await prisma.project.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
|
||||
if (!(await ownedProject(id, auth.user.id))) {
|
||||
return jsonError("Projekt nicht gefunden", 404);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = noteCreateSchema.safeParse(body);
|
||||
const parsed = noteCreateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { parseRepoUrl } from "@/lib/git";
|
||||
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError, projectUpdateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -9,29 +11,32 @@ export const dynamic = "force-dynamic";
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
include: projectDetailInclude,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
// Fremde Projekte werden wie nicht vorhandene behandelt – die Antwort
|
||||
// verraet also nicht, dass es die ID gibt.
|
||||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
return NextResponse.json({ project });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = projectUpdateSchema.safeParse(body);
|
||||
const parsed = projectUpdateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
@@ -39,19 +44,38 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.project.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
});
|
||||
if (!existing) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.connectionId) {
|
||||
const connection = await prisma.gitConnection.findFirst({
|
||||
where: { id: data.connectionId, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
if (data.name !== undefined && data.name !== existing.name) {
|
||||
update.name = data.name;
|
||||
update.slug = await uniqueSlug(data.name, id);
|
||||
update.slug = await uniqueSlug(data.name, auth.user.id, id);
|
||||
}
|
||||
for (const key of ["summary", "description", "status", "priority", "progress", "accent", "tags", "favorite", "connectionId"] as const) {
|
||||
for (const key of [
|
||||
"summary",
|
||||
"description",
|
||||
"status",
|
||||
"priority",
|
||||
"progress",
|
||||
"accent",
|
||||
"tags",
|
||||
"favorite",
|
||||
"connectionId",
|
||||
] as const) {
|
||||
if (data[key] !== undefined) update[key] = data[key];
|
||||
}
|
||||
|
||||
@@ -79,11 +103,16 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
await prisma.project.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
// deleteMany mit Besitzerbedingung: trifft fremde Projekte gar nicht erst.
|
||||
const result = await prisma.project.deleteMany({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
});
|
||||
if (result.count === 0) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { projectDetailInclude } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
import { syncProject } from "@/lib/sync";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -8,12 +11,31 @@ export const dynamic = "force-dynamic";
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Holt Branches, Tags, Commits und Releases beim Git-System ab. */
|
||||
export async function POST(_request: Request, { params }: Params) {
|
||||
export async function POST(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
|
||||
// begrenzt als bei reinen Datenbankzugriffen.
|
||||
const limited = rateLimit(
|
||||
`outbound:${auth.user.id}:${clientIp(request)}`,
|
||||
LIMITS.outbound.limit,
|
||||
LIMITS.outbound.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const owned = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
const result = await syncProject(id);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
include: projectDetailInclude,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { parseRepoUrl } from "@/lib/git";
|
||||
import { projectListInclude, uniqueSlug } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
import { formatZodError, projectCreateSchema } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
const query = searchParams.get("q");
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
// Die Besitzerbedingung steht immer zuerst und ist nicht optional.
|
||||
ownerId: auth.user.id,
|
||||
...(status ? { status: status as never } : {}),
|
||||
...(query
|
||||
? {
|
||||
@@ -32,14 +40,20 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
|
||||
}
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const parsed = projectCreateSchema.safeParse(body);
|
||||
const limited = rateLimit(
|
||||
`mutate:${clientIp(request)}`,
|
||||
LIMITS.mutation.limit,
|
||||
LIMITS.mutation.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = projectCreateSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
@@ -49,6 +63,15 @@ export async function POST(request: Request) {
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
// Eine fremde Verbindung darf nicht untergeschoben werden.
|
||||
if (data.connectionId) {
|
||||
const connection = await prisma.gitConnection.findFirst({
|
||||
where: { id: data.connectionId, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||||
}
|
||||
|
||||
// Owner/Name aus der URL ableiten, falls nicht explizit angegeben.
|
||||
let repoOwner = data.repoOwner ?? null;
|
||||
let repoName = data.repoName ?? null;
|
||||
@@ -62,8 +85,9 @@ export async function POST(request: Request) {
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
ownerId: auth.user.id,
|
||||
name: data.name,
|
||||
slug: await uniqueSlug(data.name),
|
||||
slug: await uniqueSlug(data.name, auth.user.id),
|
||||
summary: data.summary ?? null,
|
||||
description: data.description ?? null,
|
||||
status: data.status,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Nebula – wiederkehrende Bausteine.
|
||||
|
||||
Bewusst in @layer components: Tailwind sortiert die Layer als
|
||||
theme → base → components → utilities. Ohne diese Zuordnung wuerden
|
||||
die Kurzschreibweisen hier (padding, border-radius …) die Utilities
|
||||
ueberschreiben, weil sie spaeter im Stylesheet stehen – aus
|
||||
"field pl-9" wuerde dann wieder das linke Standardpadding und das
|
||||
Suchsymbol laege auf dem Platzhaltertext.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
@layer components {
|
||||
/* ── Glasflaechen ─────────────────────────────────────────── */
|
||||
|
||||
.glass {
|
||||
position: relative;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--glass-highlight) inset,
|
||||
0 24px 60px -30px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.glass-soft {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
/* Karte mit Akzent-Glow. --accent-from/--accent-to setzt die Komponente. */
|
||||
.card-glow {
|
||||
transition:
|
||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.35s ease,
|
||||
border-color 0.35s ease;
|
||||
}
|
||||
|
||||
.card-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-glow:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--glass-highlight) inset,
|
||||
0 30px 70px -30px rgba(0, 0, 0, 0.95),
|
||||
0 0 46px -12px var(--accent-from, #8b5cf6);
|
||||
}
|
||||
|
||||
.card-glow:hover::before {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Text & Akzente ───────────────────────────────────────── */
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(120deg, #c4b5fd 0%, #67e8f9 45%, #f0abfc 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
background: linear-gradient(90deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
||||
}
|
||||
|
||||
.accent-dot {
|
||||
box-shadow: 0 0 12px 1px currentColor;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.55rem 1rem;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
filter 0.2s ease;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid rgba(167, 139, 250, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(120deg, #7c3aed, #c026d3);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px -12px rgba(168, 85, 247, 0.9);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 14px 36px -12px rgba(168, 85, 247, 1);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
border-color: rgba(244, 63, 94, 0.35);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(244, 63, 94, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Formularfelder ───────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(8, 10, 24, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
color: #e6e8f5;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
border-color: rgba(167, 139, 250, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(148, 163, 184, 0.85);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
select.field option {
|
||||
background: #0a0c1a;
|
||||
color: #e6e8f5;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ConnectionsManager } from "@/components/ConnectionsManager";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { connectionSafeSelect } from "@/lib/projects";
|
||||
import type { ConnectionWithCount } from "@/types";
|
||||
@@ -8,11 +9,14 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Git-Verbindungen",
|
||||
description: "GitHub, GitLab und Gitea mit Nebula verbinden.",
|
||||
description: "GitHub, GitLab und Gitea verbinden.",
|
||||
};
|
||||
|
||||
export default async function ConnectionsPage() {
|
||||
const user = await requireUserPage();
|
||||
|
||||
const connections = await prisma.gitConnection.findMany({
|
||||
where: { ownerId: user.id },
|
||||
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
+49
-207
@@ -1,8 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "./components.css";
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
Nebula – Designsystem
|
||||
Dark Glassmorphism mit Neon-Akzenten.
|
||||
|
||||
Hier stehen Theme-Variablen, Grundlayout und Hintergrund.
|
||||
Die wiederverwendbaren Bausteine (.glass, .btn, .field …) liegen in
|
||||
components.css, damit sie in @layer components landen und Tailwind-
|
||||
Utilities sie ueberschreiben koennen.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
@theme {
|
||||
@@ -25,22 +31,50 @@
|
||||
--glass-highlight: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: #e6e8f5;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: #e6e8f5;
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(167, 139, 250, 0.5);
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hintergrund: langsam driftende Nebel ───────────────────── */
|
||||
@@ -125,176 +159,7 @@ body {
|
||||
to { transform: translate3d(4rem, -6rem, 0) scale(1.1); }
|
||||
}
|
||||
|
||||
/* ── Glasflaechen ───────────────────────────────────────────── */
|
||||
|
||||
.glass {
|
||||
position: relative;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(18px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--glass-highlight) inset,
|
||||
0 24px 60px -30px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.glass-soft {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 0.9rem;
|
||||
}
|
||||
|
||||
/* Karte mit Akzent-Glow. --accent-from/--accent-to setzt die Komponente. */
|
||||
.card-glow {
|
||||
transition:
|
||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.35s ease,
|
||||
border-color 0.35s ease;
|
||||
}
|
||||
|
||||
.card-glow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-glow:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 1px 0 0 var(--glass-highlight) inset,
|
||||
0 30px 70px -30px rgba(0, 0, 0, 0.95),
|
||||
0 0 46px -12px var(--accent-from, #8b5cf6);
|
||||
}
|
||||
|
||||
.card-glow:hover::before {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Text & Akzente ─────────────────────────────────────────── */
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(120deg, #c4b5fd 0%, #67e8f9 45%, #f0abfc 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
background: linear-gradient(90deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
||||
}
|
||||
|
||||
.accent-dot {
|
||||
box-shadow: 0 0 12px 1px currentColor;
|
||||
}
|
||||
|
||||
/* ── Buttons ────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.55rem 1rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(120deg, #7c3aed, #c026d3);
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 30px -12px rgba(168, 85, 247, 0.9);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 14px 36px -12px rgba(168, 85, 247, 1);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
border-color: rgba(244, 63, 94, 0.35);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: rgba(244, 63, 94, 0.22);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Formularfelder ─────────────────────────────────────────── */
|
||||
|
||||
.field {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(8, 10, 24, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
padding: 0.6rem 0.85rem;
|
||||
font-size: 0.875rem;
|
||||
color: #e6e8f5;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: rgba(148, 163, 184, 0.6);
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
outline: none;
|
||||
border-color: rgba(167, 139, 250, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.18);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(148, 163, 184, 0.85);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
select.field option {
|
||||
background: #0a0c1a;
|
||||
color: #e6e8f5;
|
||||
}
|
||||
|
||||
/* ── Sonstiges ──────────────────────────────────────────────── */
|
||||
/* ── Bewegung & Effekte ─────────────────────────────────────── */
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
@@ -331,29 +196,6 @@ select.field option {
|
||||
100% { box-shadow: 0 0 0 0 transparent; opacity: 0; }
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.22);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(167, 139, 250, 0.5);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
+25
-15
@@ -1,16 +1,26 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Nebula – Vibe Coding Dashboard",
|
||||
template: "%s · Nebula",
|
||||
},
|
||||
description:
|
||||
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
|
||||
applicationName: "Nebula",
|
||||
};
|
||||
/**
|
||||
* generateMetadata statt eines konstanten metadata-Objekts: nur so wird
|
||||
* APP_NAME zur Laufzeit gelesen. Ein konstanter Export waere beim Build
|
||||
* eingefroren und liesse sich im fertigen Docker-Image nicht mehr aendern.
|
||||
*/
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: {
|
||||
default: `${APP_NAME} – Vibe Coding Dashboard`,
|
||||
template: `%s · ${APP_NAME}`,
|
||||
},
|
||||
description:
|
||||
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
|
||||
applicationName: APP_NAME,
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#05060f",
|
||||
@@ -18,9 +28,11 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const user = await getSessionUser();
|
||||
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">
|
||||
@@ -31,7 +43,7 @@ export default function RootLayout({
|
||||
</div>
|
||||
<div className="aurora-grid" aria-hidden="true" />
|
||||
|
||||
<TopNav />
|
||||
<TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} />
|
||||
|
||||
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
@@ -39,10 +51,8 @@ export default function RootLayout({
|
||||
|
||||
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
|
||||
<span>
|
||||
Nebula · lokal betrieben, Daten bleiben in deiner PostgreSQL.
|
||||
</span>
|
||||
<span className="font-mono text-slate-600">v0.1.0</span>
|
||||
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
|
||||
<span className="font-mono text-slate-600">v0.2.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { LoginForm } from "@/components/auth/LoginForm";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { APP_NAME, WEBAUTHN_AVAILABLE } from "@/lib/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return { title: "Anmelden" };
|
||||
}
|
||||
|
||||
type PageProps = { searchParams: Promise<{ weiter?: string }> };
|
||||
|
||||
export default async function LoginPage({ searchParams }: PageProps) {
|
||||
const [user, state, params] = await Promise.all([
|
||||
getSessionUser(),
|
||||
getInstanceState(),
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
if (state.needsSetup) redirect("/setup");
|
||||
if (user) redirect("/");
|
||||
|
||||
// Nur relative Pfade zulassen – sonst waere das ein offener Redirect.
|
||||
const raw = params.weiter ?? "/";
|
||||
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
appName={APP_NAME}
|
||||
title="Willkommen zurueck"
|
||||
subtitle="Melde dich an, um deine Projekte zu sehen."
|
||||
>
|
||||
<LoginForm
|
||||
passkeysAvailable={WEBAUTHN_AVAILABLE}
|
||||
registrationOpen={state.registrationOpen}
|
||||
next={next}
|
||||
/>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
|
||||
import { ProjectBoard } from "@/components/ProjectBoard";
|
||||
import { StatCard } from "@/components/StatCard";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { connectionSafeSelect, projectListInclude } from "@/lib/projects";
|
||||
import { STATUS_META } from "@/lib/status";
|
||||
@@ -9,12 +10,16 @@ import type { ConnectionSafe, ProjectListItem } from "@/types";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUserPage();
|
||||
|
||||
const [projects, connections] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { ownerId: user.id },
|
||||
include: projectListInclude,
|
||||
orderBy: [{ favorite: "desc" }, { updatedAt: "desc" }],
|
||||
}),
|
||||
prisma.gitConnection.findMany({
|
||||
where: { ownerId: user.id },
|
||||
select: connectionSafeSelect,
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,8 @@ import { FileText } from "lucide-react";
|
||||
import { GitPanel } from "@/components/GitPanel";
|
||||
import { NotesPanel } from "@/components/NotesPanel";
|
||||
import { ProjectHeader } from "@/components/ProjectHeader";
|
||||
import { requireUserPage } from "@/lib/auth/guard";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects";
|
||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||
@@ -13,9 +15,12 @@ export const dynamic = "force-dynamic";
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { title: "Projekt" };
|
||||
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, ownerId: user.id },
|
||||
select: { name: true, summary: true },
|
||||
});
|
||||
return {
|
||||
@@ -25,11 +30,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: PageProps) {
|
||||
const user = await requireUserPage();
|
||||
const { id } = await params;
|
||||
|
||||
const [project, connections] = await Promise.all([
|
||||
prisma.project.findUnique({ where: { id }, include: projectDetailInclude }),
|
||||
prisma.gitConnection.findMany({ select: connectionSafeSelect, orderBy: { name: "asc" } }),
|
||||
// findFirst mit Besitzerbedingung: ein fremdes Projekt fuehrt zu 404,
|
||||
// nicht zu einer Fehlermeldung, die seine Existenz bestaetigt.
|
||||
prisma.project.findFirst({
|
||||
where: { id, ownerId: user.id },
|
||||
include: projectDetailInclude,
|
||||
}),
|
||||
prisma.gitConnection.findMany({
|
||||
where: { ownerId: user.id },
|
||||
select: connectionSafeSelect,
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!project) notFound();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { RegisterForm } from "@/components/auth/RegisterForm";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return { title: "Registrieren" };
|
||||
}
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
|
||||
|
||||
if (state.needsSetup) redirect("/setup");
|
||||
if (user) redirect("/");
|
||||
if (!state.registrationOpen) redirect("/login");
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
appName={APP_NAME}
|
||||
title="Konto anlegen"
|
||||
subtitle="Deine Projekte, Notizen und Git-Verbindungen sind nur fuer dich sichtbar."
|
||||
>
|
||||
<RegisterForm />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthShell } from "@/components/auth/AuthShell";
|
||||
import { SetupForm } from "@/components/auth/SetupForm";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return { title: "Einrichtung" };
|
||||
}
|
||||
|
||||
export default async function SetupPage() {
|
||||
const state = await getInstanceState();
|
||||
|
||||
// Nach der Einrichtung ist diese Seite verschlossen.
|
||||
if (!state.needsSetup) redirect("/login");
|
||||
|
||||
// Bestandsdaten anzeigen, damit klar ist, was das neue Konto uebernimmt.
|
||||
const [orphanProjects, orphanConnections] = await Promise.all([
|
||||
prisma.project.count({ where: { ownerId: null } }),
|
||||
prisma.gitConnection.count({ where: { ownerId: null } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
appName={APP_NAME}
|
||||
title="Erste Einrichtung"
|
||||
subtitle="Lege das Administratorkonto an. Danach ist diese Seite gesperrt."
|
||||
footer={
|
||||
orphanProjects + orphanConnections > 0 ? (
|
||||
<>
|
||||
{orphanProjects} vorhandene Projekte und {orphanConnections} Git-Verbindungen
|
||||
werden diesem Konto zugeordnet.
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<SetupForm />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
+153
-25
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { GitBranch, LayoutDashboard, Sparkles } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
GitBranch,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import type { SessionUser } from "@/lib/auth/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS = [
|
||||
@@ -10,7 +20,15 @@ const LINKS = [
|
||||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
||||
];
|
||||
|
||||
export function TopNav() {
|
||||
export function TopNav({
|
||||
user,
|
||||
appName,
|
||||
tagline,
|
||||
}: {
|
||||
user: SessionUser | null;
|
||||
appName: string;
|
||||
tagline: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
@@ -22,35 +40,145 @@ export function TopNav() {
|
||||
</span>
|
||||
<span className="flex flex-col leading-none">
|
||||
<span className="text-gradient text-lg font-semibold tracking-tight">
|
||||
Nebula
|
||||
{appName}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||
Vibe Coding
|
||||
{tagline}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-white/8 text-white"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{user ? (
|
||||
<>
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
? "bg-white/8 text-white"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto">
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SessionUser }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function logout() {
|
||||
setBusy(true);
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
setOpen(false);
|
||||
router.replace("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const label = user.displayName?.trim() || user.username;
|
||||
const initials = label.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
|
||||
{user.role === "ADMIN" ? (
|
||||
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
|
||||
>
|
||||
<div className="border-b border-white/6 px-3 py-2">
|
||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
||||
<p className="truncate text-[11px] text-slate-500">
|
||||
@{user.username}
|
||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Mein Konto
|
||||
</Link>
|
||||
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Admin-Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import {
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Monitor,
|
||||
Pencil,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||
|
||||
export interface PasskeyEntry {
|
||||
id: string;
|
||||
name: string | null;
|
||||
deviceType: string | null;
|
||||
backedUp: boolean;
|
||||
createdAt: string | Date;
|
||||
lastUsedAt: string | Date | null;
|
||||
}
|
||||
|
||||
export interface SessionEntry {
|
||||
id: string;
|
||||
userAgent: string | null;
|
||||
ip: string | null;
|
||||
createdAt: string | Date;
|
||||
lastSeenAt: string | Date;
|
||||
}
|
||||
|
||||
export interface AccountData {
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: "ADMIN" | "USER";
|
||||
hasPassword: boolean;
|
||||
passkeys: PasskeyEntry[];
|
||||
sessions: SessionEntry[];
|
||||
passkeysAvailable: boolean;
|
||||
}
|
||||
|
||||
type Notice = { ok: boolean; text: string } | null;
|
||||
|
||||
export function AccountManager({ data }: { data: AccountData }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProfileSection data={data} />
|
||||
<PasswordSection hasPassword={data.hasPassword} />
|
||||
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
||||
<SessionSection sessions={data.sessions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: typeof KeyRound;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NoticeLine({ notice }: { notice: Notice }) {
|
||||
if (!notice) return null;
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||
notice.ok
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
)}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSection({ data }: { data: AccountData }) {
|
||||
const router = useRouter();
|
||||
const [displayName, setDisplayName] = useState(data.displayName ?? "");
|
||||
const [email, setEmail] = useState(data.email ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ displayName, email }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
||||
setNotice({ ok: true, text: "Profil gespeichert." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="Profil" icon={Pencil}>
|
||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="field-label">Benutzername</span>
|
||||
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
|
||||
<p className="mt-1 text-[11px] text-slate-600">
|
||||
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
|
||||
</p>
|
||||
</div>
|
||||
<div />
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="account-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="account-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="optional"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
Speichern
|
||||
</button>
|
||||
<NoticeLine notice={notice} />
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordSection({ hasPassword }: { hasPassword: boolean }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (newPassword !== repeat) {
|
||||
setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/account/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen.");
|
||||
setNotice({
|
||||
ok: true,
|
||||
text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.",
|
||||
});
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setRepeat("");
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
|
||||
icon={KeyRound}
|
||||
description={
|
||||
hasPassword
|
||||
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
|
||||
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
|
||||
}
|
||||
>
|
||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
||||
{hasPassword ? (
|
||||
<div className="sm:col-span-2">
|
||||
<label className="field-label" htmlFor="account-current">
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input
|
||||
id="account-current"
|
||||
className="field"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-new">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
id="account-new"
|
||||
className="field"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="account-repeat">
|
||||
Wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="account-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
||||
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
|
||||
</button>
|
||||
<NoticeLine notice={notice} />
|
||||
</div>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeySection({
|
||||
passkeys,
|
||||
available,
|
||||
}: {
|
||||
passkeys: PasskeyEntry[];
|
||||
available: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function addPasskey() {
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const optionsResponse = await fetch("/api/auth/passkey/register/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden.");
|
||||
}
|
||||
|
||||
const attestation = await startRegistration({ optionsJSON: optionsPayload.options });
|
||||
|
||||
const suggestion =
|
||||
typeof navigator !== "undefined" && /mac/i.test(navigator.platform)
|
||||
? "Mac"
|
||||
: typeof navigator !== "undefined" && /win/i.test(navigator.platform)
|
||||
? "Windows"
|
||||
: "Geraet";
|
||||
|
||||
const verifyResponse = await fetch("/api/auth/passkey/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: attestation, name: suggestion }),
|
||||
});
|
||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
||||
}
|
||||
|
||||
setNotice({ ok: true, text: "Passkey hinzugefuegt." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename(entry: PasskeyEntry) {
|
||||
const name = window.prompt("Name des Passkeys", entry.name ?? "");
|
||||
if (name === null) return;
|
||||
setBusyId(entry.id);
|
||||
await fetch(`/api/account/passkeys/${entry.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}).catch(() => undefined);
|
||||
setBusyId(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function remove(entry: PasskeyEntry) {
|
||||
if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return;
|
||||
setBusyId(entry.id);
|
||||
try {
|
||||
const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Passkeys"
|
||||
icon={Fingerprint}
|
||||
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
|
||||
>
|
||||
{!available ? (
|
||||
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
||||
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
|
||||
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
|
||||
oder auf http://localhost zeigen.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
|
||||
) : (
|
||||
passkeys.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
|
||||
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
|
||||
{entry.name ?? "Unbenannter Passkey"}
|
||||
{entry.backedUp ? (
|
||||
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
|
||||
synchronisiert
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
angelegt {formatDateTime(entry.createdAt)}
|
||||
{entry.lastUsedAt
|
||||
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
|
||||
: " · noch nie genutzt"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{busyId === entry.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rename(entry)}
|
||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
|
||||
aria-label="Umbenennen"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(entry)}
|
||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4" />
|
||||
)}
|
||||
Passkey hinzufuegen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<NoticeLine notice={notice} />
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSection({ sessions }: { sessions: SessionEntry[] }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function logoutEverywhere() {
|
||||
if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return;
|
||||
setBusy(true);
|
||||
await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined);
|
||||
setBusy(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Aktive Sitzungen"
|
||||
icon={Monitor}
|
||||
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="glass-soft px-3 py-2.5">
|
||||
<p className="truncate text-sm text-slate-300">
|
||||
{shortenUserAgent(session.userAgent)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
|
||||
angemeldet {formatDateTime(session.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger mt-4"
|
||||
onClick={logoutEverywhere}
|
||||
disabled={busy || sessions.length <= 1}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
|
||||
Ueberall abmelden
|
||||
</button>
|
||||
{sessions.length <= 1 ? (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
Nur dieses Geraet ist angemeldet.
|
||||
</p>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */
|
||||
function shortenUserAgent(userAgent: string | null): string {
|
||||
if (!userAgent) return "Unbekanntes Geraet";
|
||||
|
||||
const browser =
|
||||
/Edg\//.test(userAgent) ? "Edge"
|
||||
: /OPR\//.test(userAgent) ? "Opera"
|
||||
: /Firefox\//.test(userAgent) ? "Firefox"
|
||||
: /Chrome\//.test(userAgent) ? "Chrome"
|
||||
: /Safari\//.test(userAgent) ? "Safari"
|
||||
: "Browser";
|
||||
|
||||
const platform =
|
||||
/Windows/.test(userAgent) ? "Windows"
|
||||
: /Macintosh|Mac OS/.test(userAgent) ? "macOS"
|
||||
: /Android/.test(userAgent) ? "Android"
|
||||
: /iPhone|iPad/.test(userAgent) ? "iOS"
|
||||
: /Linux/.test(userAgent) ? "Linux"
|
||||
: "";
|
||||
|
||||
return platform ? `${browser} auf ${platform}` : browser;
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Fingerprint,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||
|
||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
||||
type Role = "ADMIN" | "USER";
|
||||
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: Role;
|
||||
isActive: boolean;
|
||||
lastLoginAt: string | Date | null;
|
||||
lockedUntil: string | Date | null;
|
||||
createdAt: string | Date;
|
||||
_count: { credentials: number; projects: number; sessions: number };
|
||||
}
|
||||
|
||||
export interface AdminData {
|
||||
mode: Mode;
|
||||
allowRegistration: boolean;
|
||||
users: AdminUser[];
|
||||
currentUserId: string;
|
||||
appUrl: string;
|
||||
passkeysAvailable: boolean;
|
||||
}
|
||||
|
||||
type Notice = { ok: boolean; text: string } | null;
|
||||
|
||||
export function AdminManager({ data }: { data: AdminData }) {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>(data.mode);
|
||||
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
||||
|
||||
async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) {
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
||||
|
||||
setMode(body.settings.mode);
|
||||
setAllowRegistration(body.settings.allowRegistration);
|
||||
setNotice({ ok: true, text: "Einstellungen gespeichert." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
// Umschalter zuruecksetzen, wenn der Server ablehnt.
|
||||
setMode(data.mode);
|
||||
setAllowRegistration(data.allowRegistration);
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Betriebsart ─────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
||||
<Users className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Betriebsart
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={mode === "SINGLE_USER"}
|
||||
disabled={busy}
|
||||
icon={User}
|
||||
title="Einzelbetrieb"
|
||||
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
|
||||
onClick={() => {
|
||||
setMode("SINGLE_USER");
|
||||
void patchSettings({ mode: "SINGLE_USER" });
|
||||
}}
|
||||
/>
|
||||
<ModeCard
|
||||
active={mode === "MULTI_USER"}
|
||||
disabled={busy}
|
||||
icon={Users}
|
||||
title="Mehrbenutzerbetrieb"
|
||||
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
|
||||
onClick={() => {
|
||||
setMode("MULTI_USER");
|
||||
void patchSettings({ mode: "MULTI_USER" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "MULTI_USER" ? (
|
||||
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRegistration}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setAllowRegistration(event.target.checked);
|
||||
void patchSettings({ allowRegistration: event.target.checked });
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
|
||||
Internet erreichbaren Instanz besser aus lassen.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
||||
notice.ok
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
||||
)}
|
||||
>
|
||||
{notice.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Instanz ─────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Instanz
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
|
||||
lassen sich nur dort aendern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
|
||||
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
|
||||
</div>
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
|
||||
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
|
||||
{data.passkeysAvailable ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
||||
verfuegbar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ban className="h-3.5 w-3.5 text-amber-300" />
|
||||
kein sicherer Kontext
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* ── Benutzer ────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
|
||||
<UserCog className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Benutzer ({data.users.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary px-3 py-1.5 text-xs"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={mode === "SINGLE_USER"}
|
||||
title={
|
||||
mode === "SINGLE_USER"
|
||||
? "Erst in den Mehrbenutzerbetrieb wechseln"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Konto anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{data.users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
isSelf={user.id === data.currentUserId}
|
||||
onEdit={() => setEditing(user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UserDialog
|
||||
open={createOpen}
|
||||
mode="create"
|
||||
onClose={() => setCreateOpen(false)}
|
||||
/>
|
||||
<UserDialog
|
||||
open={editing !== null}
|
||||
mode="edit"
|
||||
user={editing}
|
||||
isSelf={editing?.id === data.currentUserId}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
active,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
title,
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
icon: typeof User;
|
||||
title: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
|
||||
active
|
||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
||||
{title}
|
||||
{active ? (
|
||||
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
isSelf,
|
||||
onEdit,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
isSelf: boolean;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked =
|
||||
user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now();
|
||||
|
||||
async function remove() {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-soft px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
|
||||
user.isActive
|
||||
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
|
||||
: "bg-slate-700/60",
|
||||
)}
|
||||
>
|
||||
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
|
||||
<span className="truncate">{user.displayName ?? user.username}</span>
|
||||
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
|
||||
{user.role === "ADMIN" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
|
||||
<ShieldCheck className="h-2.5 w-2.5" />
|
||||
Admin
|
||||
</span>
|
||||
) : null}
|
||||
{isSelf ? (
|
||||
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
du
|
||||
</span>
|
||||
) : null}
|
||||
{!user.isActive ? (
|
||||
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
|
||||
deaktiviert
|
||||
</span>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
gesperrt
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FolderGit2 className="h-3 w-3" />
|
||||
{user._count.projects} Projekte
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Fingerprint className="h-3 w-3" />
|
||||
{user._count.credentials} Passkeys
|
||||
</span>
|
||||
<span suppressHydrationWarning>
|
||||
{user.lastLoginAt
|
||||
? `zuletzt ${formatRelative(user.lastLoginAt)}`
|
||||
: "noch nie angemeldet"}
|
||||
</span>
|
||||
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||
disabled={busy}
|
||||
>
|
||||
<UserCog className="h-3.5 w-3.5" />
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="btn btn-danger px-2.5 py-1.5 text-xs"
|
||||
disabled={busy || isSelf}
|
||||
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDialog({
|
||||
open,
|
||||
mode,
|
||||
user,
|
||||
isSelf,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
user?: AdminUser | null;
|
||||
isSelf?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>("USER");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
||||
|
||||
// Formular beim Oeffnen einmalig aus dem Datensatz fuellen.
|
||||
const key = open ? (user?.id ?? "new") : null;
|
||||
if (key !== null && key !== loadedFor) {
|
||||
setLoadedFor(key);
|
||||
setUsername(user?.username ?? "");
|
||||
setDisplayName(user?.displayName ?? "");
|
||||
setEmail(user?.email ?? "");
|
||||
setPassword("");
|
||||
setRole(user?.role ?? "USER");
|
||||
setIsActive(user?.isActive ?? true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
const body =
|
||||
mode === "create"
|
||||
? { username, displayName, email, password, role }
|
||||
: { displayName, email, role, isActive, password: password || null };
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`,
|
||||
{
|
||||
method: mode === "create" ? "POST" : "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
||||
}
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
}}
|
||||
title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`}
|
||||
description={
|
||||
mode === "create"
|
||||
? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen."
|
||||
: "Ein neues Passwort beendet alle Sitzungen dieses Kontos."
|
||||
}
|
||||
width="md"
|
||||
>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{mode === "create" ? (
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="admin-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="admin-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="admin-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="admin-password">
|
||||
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
|
||||
</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
|
||||
required={mode === "create"}
|
||||
/>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="field-label">Rolle</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(["USER", "ADMIN"] as Role[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setRole(value)}
|
||||
disabled={isSelf && value === "USER"}
|
||||
title={
|
||||
isSelf && value === "USER"
|
||||
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
|
||||
role === value
|
||||
? "border-violet-400/60 bg-violet-500/12 text-white"
|
||||
: "border-white/9 text-slate-400 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
{value === "ADMIN" ? "Administrator" : "Benutzer"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "edit" ? (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(event) => setIsActive(event.target.checked)}
|
||||
disabled={isSelf}
|
||||
className="h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
Konto aktiv
|
||||
{isSelf ? (
|
||||
<span className="text-[11px] text-slate-600">
|
||||
(das eigene Konto laesst sich nicht deaktivieren)
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setLoadedFor(null);
|
||||
onClose();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
/** Zentrierte Karte fuer Einrichtung, Anmeldung und Registrierung. */
|
||||
export function AuthShell({
|
||||
appName,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
appName: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-10">
|
||||
<div className="glass fade-up relative overflow-hidden p-7">
|
||||
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
|
||||
<div
|
||||
className="pointer-events-none absolute -right-24 -top-24 h-56 w-56 rounded-full opacity-25 blur-3xl"
|
||||
style={{ background: "radial-gradient(circle, #8b5cf6, transparent 70%)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400">
|
||||
<Sparkles className="h-5 w-5 text-white" strokeWidth={2.4} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-gradient text-lg font-semibold leading-none">{appName}</p>
|
||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
||||
Vibe Coding Dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-semibold text-white">{title}</h1>
|
||||
{subtitle ? <p className="mt-1.5 text-sm text-slate-400">{subtitle}</p> : null}
|
||||
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
|
||||
{footer ? <div className="mt-4 text-center text-sm text-slate-500">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react";
|
||||
|
||||
export function LoginForm({
|
||||
passkeysAvailable,
|
||||
registrationOpen,
|
||||
next,
|
||||
}: {
|
||||
passkeysAvailable: boolean;
|
||||
registrationOpen: boolean;
|
||||
next: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState<"password" | "passkey" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function done() {
|
||||
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
|
||||
router.replace(next);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function loginWithPassword(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy("password");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
|
||||
done();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
setBusy("passkey");
|
||||
setError(null);
|
||||
try {
|
||||
const optionsResponse = await fetch("/api/auth/passkey/login/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
||||
if (!optionsResponse.ok) {
|
||||
throw new Error(optionsPayload.error ?? "Passkey-Anmeldung nicht moeglich.");
|
||||
}
|
||||
|
||||
const assertion = await startAuthentication({
|
||||
optionsJSON: optionsPayload.options,
|
||||
});
|
||||
|
||||
const verifyResponse = await fetch("/api/auth/passkey/login/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ response: assertion }),
|
||||
});
|
||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
||||
}
|
||||
done();
|
||||
} catch (caught) {
|
||||
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||
const message = caught instanceof Error ? caught.message : String(caught);
|
||||
setError(/abort|NotAllowed/i.test(message) ? null : message);
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{passkeysAvailable ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loginWithPasskey}
|
||||
disabled={busy !== null}
|
||||
className="btn btn-ghost w-full py-2.5"
|
||||
>
|
||||
{busy === "passkey" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Fingerprint className="h-4 w-4 text-violet-300" />
|
||||
)}
|
||||
Mit Passkey anmelden
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wider text-slate-600">
|
||||
<span className="h-px flex-1 bg-white/8" />
|
||||
oder
|
||||
<span className="h-px flex-1 bg-white/8" />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={loginWithPassword} className="space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="login-username">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username webauthn"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="login-password">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
|
||||
{busy === "password" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{registrationOpen ? (
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Noch kein Konto?{" "}
|
||||
<Link href="/register" className="text-violet-300 hover:text-violet-200">
|
||||
Registrieren
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!passkeysAvailable ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-[11px] leading-snug text-slate-500">
|
||||
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Passkeys sind deaktiviert: Sie brauchen einen sicheren Kontext. Dafuer muss
|
||||
<code className="mx-1 text-violet-300">APP_URL</code>
|
||||
auf eine https-Adresse oder auf http://localhost zeigen.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
if (password !== repeat) {
|
||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName, email, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Registrierung fehlgeschlagen.");
|
||||
}
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="register-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="register-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="optional"
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-password">
|
||||
Passwort *
|
||||
</label>
|
||||
<input
|
||||
id="register-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
/>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="register-repeat">
|
||||
Passwort wiederholen *
|
||||
</label>
|
||||
<input
|
||||
id="register-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.repeat ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
||||
Konto anlegen
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
Schon ein Konto?{" "}
|
||||
<Link href="/login" className="text-violet-300 hover:text-violet-200">
|
||||
Anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Rocket, User, Users } from "lucide-react";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
||||
|
||||
const MODES: Array<{ value: Mode; label: string; hint: string; icon: typeof User }> = [
|
||||
{
|
||||
value: "SINGLE_USER",
|
||||
label: "Nur ich",
|
||||
hint: "Ein Konto, keine Registrierung, keine Benutzerliste.",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
value: "MULTI_USER",
|
||||
label: "Mehrere Personen",
|
||||
hint: "Weitere Konten moeglich, jedes mit streng getrennten Daten.",
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
export function SetupForm() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [mode, setMode] = useState<Mode>("SINGLE_USER");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
|
||||
if (password !== repeat) {
|
||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/auth/setup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName, email, password, mode }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
setFieldErrors(payload.fields ?? {});
|
||||
throw new Error(payload.error ?? "Einrichtung fehlgeschlagen.");
|
||||
}
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-5">
|
||||
<div>
|
||||
<span className="field-label">Betriebsart</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{MODES.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const active = mode === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-3 text-left transition-all",
|
||||
active
|
||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
||||
{option.label}
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-snug text-slate-500">
|
||||
{option.hint}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
||||
Laesst sich spaeter in den Admin-Einstellungen umstellen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-username">
|
||||
Benutzername *
|
||||
</label>
|
||||
<input
|
||||
id="setup-username"
|
||||
className="field"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="florian"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
{fieldErrors.username ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-display">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="setup-display"
|
||||
className="field"
|
||||
value={displayName}
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder="Florian"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-email">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="setup-email"
|
||||
className="field"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
placeholder="optional"
|
||||
/>
|
||||
{fieldErrors.email ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-password">
|
||||
Passwort *
|
||||
</label>
|
||||
<input
|
||||
id="setup-password"
|
||||
className="field"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
Mindestens {PASSWORD_MIN_LENGTH} Zeichen. Einen Passkey kannst du danach unter
|
||||
„Mein Konto“ hinzufuegen.
|
||||
</p>
|
||||
{fieldErrors.password ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="setup-repeat">
|
||||
Passwort wiederholen *
|
||||
</label>
|
||||
<input
|
||||
id="setup-repeat"
|
||||
className="field"
|
||||
type="password"
|
||||
value={repeat}
|
||||
onChange={(event) => setRepeat(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.repeat ? (
|
||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Rocket className="h-4 w-4" />}
|
||||
Einrichten und loslegen
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Konstanten, die Server und Browser gemeinsam brauchen.
|
||||
*
|
||||
* Bewusst in einer eigenen Datei ohne node:crypto-Import: password.ts laesst
|
||||
* sich in Client-Komponenten nicht einbinden, diese Werte schon.
|
||||
*/
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 10;
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 32;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
|
||||
import { getInstanceState } from "@/lib/auth/settings";
|
||||
|
||||
/**
|
||||
* Zugriffsschutz.
|
||||
*
|
||||
* Die Middleware prueft nur, ob ueberhaupt ein Sitzungscookie mitkommt – im
|
||||
* Edge-Runtime steht keine Datenbank zur Verfuegung. Die eigentliche
|
||||
* Entscheidung faellt hier, serverseitig, und zwar in jeder Seite und jedem
|
||||
* API-Endpunkt. Das ist die Sicherheitsgrenze.
|
||||
*/
|
||||
|
||||
export type { SessionUser };
|
||||
|
||||
/** Fuer Server Components: leitet auf /login bzw. /setup um. */
|
||||
export async function requireUserPage(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (user) return user;
|
||||
|
||||
const state = await getInstanceState();
|
||||
redirect(state.needsSetup ? "/setup" : "/login");
|
||||
}
|
||||
|
||||
/** Wie requireUserPage, verlangt zusaetzlich die Administratorrolle. */
|
||||
export async function requireAdminPage(): Promise<SessionUser> {
|
||||
const user = await requireUserPage();
|
||||
if (user.role !== "ADMIN") redirect("/");
|
||||
return user;
|
||||
}
|
||||
|
||||
export type ApiAuth =
|
||||
| { ok: true; user: SessionUser }
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/** Fuer Route Handler: liefert entweder den Benutzer oder eine 401/403-Antwort. */
|
||||
export async function requireUserApi(): Promise<ApiAuth> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
return { ok: true, user };
|
||||
}
|
||||
|
||||
export async function requireAdminApi(): Promise<ApiAuth> {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth;
|
||||
if (auth.user.role !== "ADMIN") {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ error: "Diese Aktion ist Administratoren vorbehalten." },
|
||||
{ status: 403 },
|
||||
),
|
||||
};
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
||||
import { promisify } from "node:util";
|
||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
||||
|
||||
/**
|
||||
* Passwort-Hashing mit scrypt aus der Node-Standardbibliothek – kein
|
||||
* zusaetzliches natives Modul noetig, und scrypt ist speicherhart, also
|
||||
* deutlich unangenehmer fuer GPU-Angriffe als PBKDF2.
|
||||
*
|
||||
* Ablageformat: scrypt$<N>$<r>$<p>$<salt>$<hash> (salt/hash base64url)
|
||||
*/
|
||||
|
||||
const scrypt = promisify(scryptCallback) as (
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
keylen: number,
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
const PARAMS = { N: 32768, r: 8, p: 1 };
|
||||
const KEY_LENGTH = 64;
|
||||
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
|
||||
// dafuer nicht ganz.
|
||||
const MAX_MEM = 96 * 1024 * 1024;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, {
|
||||
...PARAMS,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
return [
|
||||
"scrypt",
|
||||
PARAMS.N,
|
||||
PARAMS.r,
|
||||
PARAMS.p,
|
||||
salt.toString("base64url"),
|
||||
derived.toString("base64url"),
|
||||
].join("$");
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!stored) return false;
|
||||
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
|
||||
const N = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
|
||||
|
||||
const salt = Buffer.from(parts[4], "base64url");
|
||||
const expected = Buffer.from(parts[5], "base64url");
|
||||
|
||||
let derived: Buffer;
|
||||
try {
|
||||
derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
|
||||
N,
|
||||
r,
|
||||
p,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (derived.length !== expected.length) return false;
|
||||
return timingSafeEqual(derived, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
|
||||
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
|
||||
* Antwortzeit ablesen, welche Konten existieren.
|
||||
*/
|
||||
export async function fakeVerifyDelay(): Promise<void> {
|
||||
await scrypt("nebula-dummy-password", "nebula-dummy-salt", KEY_LENGTH, {
|
||||
...PARAMS,
|
||||
maxmem: MAX_MEM,
|
||||
});
|
||||
}
|
||||
|
||||
const OBVIOUS = new Set([
|
||||
"password", "passwort", "12345678", "123456789", "1234567890",
|
||||
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
|
||||
"willkommen", "nebula123", "geheim123",
|
||||
]);
|
||||
|
||||
export { PASSWORD_MIN_LENGTH };
|
||||
|
||||
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
|
||||
export function validatePasswordStrength(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
|
||||
}
|
||||
if (password.length > 200) {
|
||||
return "Das Passwort darf hoechstens 200 Zeichen haben.";
|
||||
}
|
||||
if (OBVIOUS.has(password.toLowerCase())) {
|
||||
return "Dieses Passwort ist zu naheliegend.";
|
||||
}
|
||||
if (/^\d+$/.test(password)) {
|
||||
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextResponse } from "next/server";
|
||||
import type { User } from "@prisma/client";
|
||||
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Serverseitige Sitzungen.
|
||||
*
|
||||
* Der Cookie enthaelt ein Zufallstoken, die Datenbank nur dessen SHA-256-Hash.
|
||||
* Wer die Datenbank liest, kann damit keine Sitzung uebernehmen. Abmelden und
|
||||
* Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird – anders
|
||||
* als bei einem selbsttragenden Token.
|
||||
*/
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return createHash("sha256").update(token).digest("base64url");
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
email: string | null;
|
||||
role: User["role"];
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
function toSessionUser(user: User): SessionUser {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
userId: string,
|
||||
meta: { userAgent?: string | null; ip?: string | null } = {},
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashToken(token),
|
||||
expiresAt,
|
||||
userAgent: meta.userAgent?.slice(0, 300) ?? null,
|
||||
ip: meta.ip?.slice(0, 60) ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// Gelegenheit nutzen und abgelaufene Sitzungen wegraeumen.
|
||||
void prisma.session
|
||||
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
||||
.catch(() => undefined);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function setSessionCookie(response: NextResponse, token: string, expiresAt: Date) {
|
||||
response.cookies.set({
|
||||
name: SESSION_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(response: NextResponse) {
|
||||
response.cookies.set({
|
||||
name: SESSION_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Sitzung aus dem Cookie und prueft sie gegen die Datenbank.
|
||||
* Gibt null zurueck, wenn kein oder ein ungueltiges Token vorliegt oder das
|
||||
* Konto deaktiviert wurde.
|
||||
*/
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expiresAt.getTime() <= Date.now()) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session.user.isActive) return null;
|
||||
|
||||
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
|
||||
// Datenbank anfasst.
|
||||
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return toSessionUser(session.user);
|
||||
}
|
||||
|
||||
export async function destroyCurrentSession(): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return;
|
||||
await prisma.session
|
||||
.deleteMany({ where: { tokenHash: hashToken(token) } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** Meldet ein Konto ueberall ab – nach Passwortwechsel oder Sperrung. */
|
||||
export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { AppSettings } from "@prisma/client";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
|
||||
* sie wird beim ersten Zugriff angelegt.
|
||||
*/
|
||||
export async function getAppSettings(): Promise<AppSettings> {
|
||||
return prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: { id: "singleton", rpName: APP_NAME },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
export interface InstanceState {
|
||||
settings: AppSettings;
|
||||
userCount: number;
|
||||
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
|
||||
needsSetup: boolean;
|
||||
/** Darf sich jemand selbst registrieren? */
|
||||
registrationOpen: boolean;
|
||||
multiUser: boolean;
|
||||
}
|
||||
|
||||
export async function getInstanceState(): Promise<InstanceState> {
|
||||
const [settings, userCount] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.count(),
|
||||
]);
|
||||
|
||||
const multiUser = settings.mode === "MULTI_USER";
|
||||
|
||||
return {
|
||||
settings,
|
||||
userCount,
|
||||
needsSetup: userCount === 0,
|
||||
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
|
||||
registrationOpen: multiUser && settings.allowRegistration,
|
||||
multiUser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Benutzerfelder, die an den Browser gehen duerfen. */
|
||||
export const userSafeSelect = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLoginAt: true,
|
||||
lockedUntil: true,
|
||||
createdAt: true,
|
||||
_count: { select: { credentials: true, projects: true, sessions: true } },
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
export type UserListEntry = Prisma.UserGetPayload<{ select: typeof userSafeSelect }>;
|
||||
|
||||
/** Sperre nach zu vielen Fehlversuchen. */
|
||||
const LOCK_THRESHOLD = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
|
||||
export async function noteFailedLogin(userId: string): Promise<void> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { failedLoginCount: { increment: 1 } },
|
||||
select: { failedLoginCount: true },
|
||||
});
|
||||
|
||||
if (user.failedLoginCount >= LOCK_THRESHOLD) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000),
|
||||
failedLoginCount: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function noteSuccessfulLogin(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { lastLoginAt: new Date(), failedLoginCount: 0, lockedUntil: null },
|
||||
});
|
||||
}
|
||||
|
||||
export function lockRemainingSeconds(lockedUntil: Date | null): number {
|
||||
if (!lockedUntil) return 0;
|
||||
const remaining = lockedUntil.getTime() - Date.now();
|
||||
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt Projekte und Verbindungen ohne Besitzer.
|
||||
*
|
||||
* Greift genau einmal: beim Anlegen des ersten Kontos. So gehen Daten, die
|
||||
* vor der Einfuehrung der Benutzerverwaltung entstanden sind, nicht verloren.
|
||||
*/
|
||||
export async function claimOrphanedData(userId: string): Promise<{
|
||||
projects: number;
|
||||
connections: number;
|
||||
}> {
|
||||
const [projects, connections] = await prisma.$transaction([
|
||||
prisma.project.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
||||
prisma.gitConnection.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
||||
]);
|
||||
return { projects: projects.count, connections: connections.count };
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
AuthenticatorTransportFuture,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from "@simplewebauthn/server";
|
||||
import type { Credential, User } from "@prisma/client";
|
||||
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Passkeys (WebAuthn).
|
||||
*
|
||||
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
|
||||
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
|
||||
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
|
||||
*
|
||||
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
|
||||
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
|
||||
* neu angelegt werden.
|
||||
*/
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export class WebAuthnUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
|
||||
"https-Adresse (oder http://localhost) setzen.",
|
||||
);
|
||||
this.name = "WebAuthnUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
function assertAvailable() {
|
||||
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
|
||||
}
|
||||
|
||||
async function storeChallenge(
|
||||
challenge: string,
|
||||
purpose: "registration" | "authentication",
|
||||
userId: string | null,
|
||||
) {
|
||||
// Abgelaufene Challenges bei der Gelegenheit entfernen.
|
||||
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
||||
await prisma.webAuthnChallenge.create({
|
||||
data: {
|
||||
challenge,
|
||||
purpose,
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
|
||||
async function consumeChallenge(
|
||||
challenge: string,
|
||||
purpose: "registration" | "authentication",
|
||||
): Promise<{ userId: string | null } | null> {
|
||||
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
|
||||
if (!stored) return null;
|
||||
|
||||
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
|
||||
|
||||
if (stored.purpose !== purpose) return null;
|
||||
if (stored.expiresAt.getTime() <= Date.now()) return null;
|
||||
|
||||
return { userId: stored.userId };
|
||||
}
|
||||
|
||||
// ── Registrierung eines neuen Passkeys ───────────────────────
|
||||
|
||||
export async function buildRegistrationOptions(
|
||||
user: Pick<User, "id" | "username" | "displayName">,
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
assertAvailable();
|
||||
|
||||
const existing = await prisma.credential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { credentialId: true, transports: true },
|
||||
});
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: APP_NAME,
|
||||
rpID: WEBAUTHN_RP_ID,
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
userName: user.username,
|
||||
userDisplayName: user.displayName ?? user.username,
|
||||
attestationType: "none",
|
||||
// Bereits registrierte Passkeys ausschliessen, damit derselbe
|
||||
// Authenticator nicht doppelt hinterlegt wird.
|
||||
excludeCredentials: existing.map((credential) => ({
|
||||
id: credential.credentialId,
|
||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "registration", user.id);
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function confirmRegistration(
|
||||
userId: string,
|
||||
response: RegistrationResponseJSON,
|
||||
name: string | null,
|
||||
): Promise<Credential> {
|
||||
assertAvailable();
|
||||
|
||||
const expectedChallenge = response.response.clientDataJSON
|
||||
? decodeChallenge(response.response.clientDataJSON)
|
||||
: null;
|
||||
if (!expectedChallenge) {
|
||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
||||
}
|
||||
|
||||
const stored = await consumeChallenge(expectedChallenge, "registration");
|
||||
if (!stored) {
|
||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
||||
}
|
||||
if (stored.userId !== userId) {
|
||||
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
||||
expectedRPID: WEBAUTHN_RP_ID,
|
||||
requireUserVerification: false,
|
||||
});
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
||||
}
|
||||
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
|
||||
return prisma.credential.create({
|
||||
data: {
|
||||
userId,
|
||||
credentialId: credential.id,
|
||||
publicKey: Buffer.from(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: (credential.transports ?? []) as string[],
|
||||
deviceType: credentialDeviceType,
|
||||
backedUp: credentialBackedUp,
|
||||
name: name?.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Anmeldung per Passkey ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
|
||||
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
|
||||
*/
|
||||
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
assertAvailable();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: WEBAUTHN_RP_ID,
|
||||
userVerification: "preferred",
|
||||
});
|
||||
|
||||
await storeChallenge(options.challenge, "authentication", null);
|
||||
return options;
|
||||
}
|
||||
|
||||
export interface PasskeyLoginResult {
|
||||
userId: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
export async function confirmAuthentication(
|
||||
response: AuthenticationResponseJSON,
|
||||
): Promise<PasskeyLoginResult> {
|
||||
assertAvailable();
|
||||
|
||||
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
|
||||
if (!expectedChallenge) {
|
||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
||||
}
|
||||
|
||||
const stored = await consumeChallenge(expectedChallenge, "authentication");
|
||||
if (!stored) {
|
||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: { credentialId: response.id },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!credential) {
|
||||
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
|
||||
}
|
||||
if (!credential.user.isActive) {
|
||||
throw new Error("Das Konto ist deaktiviert.");
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
||||
expectedRPID: WEBAUTHN_RP_ID,
|
||||
requireUserVerification: false,
|
||||
credential: {
|
||||
id: credential.credentialId,
|
||||
publicKey: new Uint8Array(credential.publicKey),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification.verified) {
|
||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
||||
}
|
||||
|
||||
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
|
||||
// SimpleWebAuthn wirft in dem Fall bereits oben.
|
||||
await prisma.credential.update({
|
||||
where: { id: credential.id },
|
||||
data: {
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return { userId: credential.userId, credentialId: credential.credentialId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
|
||||
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
|
||||
* zu finden und zu entwerten.
|
||||
*/
|
||||
function decodeChallenge(clientDataJSON: string): string | null {
|
||||
try {
|
||||
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
|
||||
challenge?: string;
|
||||
};
|
||||
return typeof json.challenge === "string" ? json.challenge : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Laufzeitkonfiguration.
|
||||
*
|
||||
* Bewusst ohne NEXT_PUBLIC_-Variablen: die wuerden beim Build fest in das
|
||||
* JavaScript-Bundle geschrieben. So laesst sich dasselbe Docker-Image ohne
|
||||
* Neubau anders benennen und unter einer anderen URL betreiben. Werte, die
|
||||
* der Browser braucht, reicht das Layout als Props weiter.
|
||||
*/
|
||||
|
||||
function trimmed(value: string | undefined): string | null {
|
||||
const result = value?.trim();
|
||||
return result ? result : null;
|
||||
}
|
||||
|
||||
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
|
||||
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
|
||||
|
||||
/** Kleine Zeile unter dem Namen in der Kopfzeile. */
|
||||
export const APP_TAGLINE = trimmed(process.env.APP_TAGLINE) ?? "Vibe Coding";
|
||||
|
||||
const RAW_APP_URL = trimmed(process.env.APP_URL) ?? "http://localhost:3000";
|
||||
|
||||
function parseAppUrl(raw: string): URL {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`APP_URL muss http oder https sein, ist aber "${url.protocol}"`);
|
||||
}
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`APP_URL ist keine gueltige URL: "${raw}". Beispiel: https://nebula.example.com` +
|
||||
` (${error instanceof Error ? error.message : String(error)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const APP_URL_PARSED = parseAppUrl(RAW_APP_URL);
|
||||
|
||||
/** Vollstaendige Basis-URL ohne abschliessenden Slash. */
|
||||
export const APP_URL = APP_URL_PARSED.origin;
|
||||
|
||||
/** Origin, den WebAuthn in der Assertion erwartet. */
|
||||
export const WEBAUTHN_ORIGIN = APP_URL_PARSED.origin;
|
||||
|
||||
/**
|
||||
* Relying-Party-ID fuer WebAuthn: der reine Hostname ohne Port.
|
||||
* Passkeys sind an diesen Wert gebunden – aendert er sich, sind bestehende
|
||||
* Passkeys nicht mehr verwendbar.
|
||||
*/
|
||||
export const WEBAUTHN_RP_ID = APP_URL_PARSED.hostname;
|
||||
|
||||
/**
|
||||
* WebAuthn verlangt einen sicheren Kontext. Ausnahme: localhost gilt in
|
||||
* Browsern als sicher, auch ohne TLS.
|
||||
*/
|
||||
export const WEBAUTHN_AVAILABLE =
|
||||
APP_URL_PARSED.protocol === "https:" ||
|
||||
APP_URL_PARSED.hostname === "localhost" ||
|
||||
APP_URL_PARSED.hostname === "127.0.0.1";
|
||||
|
||||
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
|
||||
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
|
||||
|
||||
/** Lebensdauer einer Sitzung. */
|
||||
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
|
||||
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const SESSION_COOKIE = "nebula_session";
|
||||
|
||||
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
||||
export function describeConfig(): string {
|
||||
return [
|
||||
`name=${APP_NAME}`,
|
||||
`url=${APP_URL}`,
|
||||
`rpId=${WEBAUTHN_RP_ID}`,
|
||||
`passkeys=${WEBAUTHN_AVAILABLE ? "moeglich" : "deaktiviert (kein sicherer Kontext)"}`,
|
||||
`secureCookies=${COOKIES_SECURE}`,
|
||||
].join(" ");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
|
||||
import { GitApiError } from "./types";
|
||||
|
||||
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
|
||||
@@ -44,6 +45,10 @@ export async function getJson<T>(
|
||||
);
|
||||
|
||||
try {
|
||||
// Vor jedem ausgehenden Aufruf pruefen, wohin er geht. Die Basis-URL
|
||||
// stammt aus einem Formular, also aus Benutzereingabe.
|
||||
await assertAllowedUrl(url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
@@ -52,8 +57,16 @@ export async function getJson<T>(
|
||||
},
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
// Ein Redirect koennte auf eine interne Adresse zeigen (etwa bei einem
|
||||
// umbenannten GitHub-Repo, aber eben auch boesartig). Deshalb das
|
||||
// tatsaechliche Ziel nachtraeglich noch einmal pruefen.
|
||||
if (response.redirected && response.url && response.url !== url) {
|
||||
await assertAllowedUrl(response.url);
|
||||
}
|
||||
|
||||
if (response.status === 404 && options.allow404) {
|
||||
return null as T;
|
||||
}
|
||||
@@ -68,6 +81,9 @@ export async function getJson<T>(
|
||||
return JSON.parse(text) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof GitApiError) throw error;
|
||||
if (error instanceof BlockedHostError) {
|
||||
throw new GitApiError(error.message, 403, url);
|
||||
}
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new GitApiError(
|
||||
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
|
||||
|
||||
+11
-4
@@ -2,16 +2,23 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { slugify } from "@/lib/utils";
|
||||
|
||||
/** Erzeugt einen eindeutigen Slug, notfalls mit angehaengtem Zaehler. */
|
||||
export async function uniqueSlug(name: string, ignoreId?: string): Promise<string> {
|
||||
/**
|
||||
* Erzeugt einen je Besitzer eindeutigen Slug, notfalls mit angehaengtem
|
||||
* Zaehler. Zwei Benutzer duerfen also beide ein Projekt "dashboard" haben.
|
||||
*/
|
||||
export async function uniqueSlug(
|
||||
name: string,
|
||||
ownerId: string,
|
||||
ignoreId?: string,
|
||||
): Promise<string> {
|
||||
const base = slugify(name) || "projekt";
|
||||
let candidate = base;
|
||||
let counter = 2;
|
||||
|
||||
// In der Praxis endet die Schleife nach wenigen Runden.
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
const existing = await prisma.project.findUnique({
|
||||
where: { slug: candidate },
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { slug: candidate, ownerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing || existing.id === ignoreId) return candidate;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Schlanker Ratenbegrenzer im Arbeitsspeicher.
|
||||
*
|
||||
* Nebula ist eine Einzelinstanz ohne geteilten Cache – ein Map genuegt.
|
||||
* Der Zaehler ueberlebt keinen Neustart, was fuer den Zweck (Bremse gegen
|
||||
* Passwortraten, Portscans ueber die Git-Endpunkte und das Verheizen fremder
|
||||
* API-Kontingente) ausreicht.
|
||||
*/
|
||||
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** Verhindert, dass die Map bei vielen unterschiedlichen Schluesseln waechst. */
|
||||
function sweep(now: number) {
|
||||
if (buckets.size < 5000) return;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
/** Sekunden bis zum naechsten erlaubten Versuch. */
|
||||
retryAfter: number;
|
||||
}
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
sweep(now);
|
||||
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||
return { ok: true, remaining: limit - 1, retryAfter: 0 };
|
||||
}
|
||||
|
||||
bucket.count += 1;
|
||||
if (bucket.count > limit) {
|
||||
return {
|
||||
ok: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, remaining: limit - bucket.count, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/** Nach erfolgreicher Anmeldung die Bremse fuer diesen Schluessel loesen. */
|
||||
export function resetRateLimit(key: string) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
|
||||
/** Vorkonfigurierte Kontingente je Anwendungsfall. */
|
||||
export const LIMITS = {
|
||||
/** Anmeldeversuche je IP – die eigentliche Bremse gegen Durchprobieren */
|
||||
login: { limit: 10, windowMs: 5 * 60 * 1000 },
|
||||
/**
|
||||
* Anmeldeversuche je Benutzername. Bewusst grosszuegiger als die
|
||||
* Kontosperre (8 Fehlversuche), damit diese zuerst greift: der Benutzer
|
||||
* bekommt dann die verstaendliche Meldung "Konto gesperrt" statt eines
|
||||
* nackten 429. Ausserdem liesse sich ein Konto sonst schon mit fuenf
|
||||
* falschen Versuchen von aussen blockieren.
|
||||
*/
|
||||
loginUser: { limit: 20, windowMs: 15 * 60 * 1000 },
|
||||
/** Ausgehende Git-Abfragen (Sync, Verbindungstest, Repo-Liste) */
|
||||
outbound: { limit: 30, windowMs: 60 * 1000 },
|
||||
/** Schreibende Zugriffe allgemein */
|
||||
mutation: { limit: 240, windowMs: 60 * 1000 },
|
||||
/** WebAuthn-Optionen anfordern */
|
||||
webauthn: { limit: 30, windowMs: 5 * 60 * 1000 },
|
||||
} as const;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/** Groesste akzeptierte JSON-Nutzlast. */
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
export function jsonError(
|
||||
message: string,
|
||||
status: number,
|
||||
extra?: Record<string, unknown>,
|
||||
): NextResponse {
|
||||
return NextResponse.json({ error: message, ...extra }, { status });
|
||||
}
|
||||
|
||||
export function tooManyRequests(retryAfter: number): NextResponse {
|
||||
return NextResponse.json(
|
||||
{ error: `Zu viele Anfragen. Bitte in ${retryAfter} Sekunden erneut versuchen.` },
|
||||
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
export type BodyResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; response: NextResponse };
|
||||
|
||||
/**
|
||||
* Liest einen JSON-Body und erzwingt dabei drei Dinge:
|
||||
*
|
||||
* 1. Content-Type application/json. Das ist zugleich CSRF-Schutz in der
|
||||
* Tiefe: ein browserseitiger Cross-Site-Angriff kommt ohne Preflight nur
|
||||
* mit text/plain, multipart/form-data oder urlencoded durch.
|
||||
* 2. Eine Obergrenze fuer die Groesse, damit ein grosser Body nicht Speicher
|
||||
* bindet.
|
||||
* 3. Sauberes JSON – Parserfehler werden zu 400 statt zu einer Ausnahme.
|
||||
*/
|
||||
export async function readJsonBody<T = unknown>(
|
||||
request: Request,
|
||||
): Promise<BodyResult<T>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return {
|
||||
ok: false,
|
||||
response: jsonError("Content-Type muss application/json sein.", 415),
|
||||
};
|
||||
}
|
||||
|
||||
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await request.text();
|
||||
} catch {
|
||||
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
|
||||
}
|
||||
|
||||
if (raw.length > MAX_BODY_BYTES) {
|
||||
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
|
||||
}
|
||||
|
||||
try {
|
||||
return { ok: true, data: JSON.parse(raw) as T };
|
||||
} catch {
|
||||
return { ok: false, response: jsonError("Ungueltiger JSON-Body.", 400) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Beste verfuegbare Naeherung der Client-Adresse – nur fuer Ratenbegrenzung. */
|
||||
export function clientIp(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const first = forwarded.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return request.headers.get("x-real-ip")?.trim() || "unbekannt";
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { isIP } from "node:net";
|
||||
import { lookup } from "node:dns/promises";
|
||||
|
||||
/**
|
||||
* Schutz vor Server Side Request Forgery.
|
||||
*
|
||||
* Die Basis-URL einer Git-Verbindung kommt aus einem Formular. Ohne Pruefung
|
||||
* koennte darueber jede vom Server erreichbare Adresse abgefragt werden –
|
||||
* Cloud-Metadaten (169.254.169.254), lokal gebundene Dienste oder ein
|
||||
* Portscan im Heimnetz.
|
||||
*
|
||||
* Voreinstellung:
|
||||
* verboten – Loopback, Link-Local (inkl. Metadaten), unspezifiziert,
|
||||
* Multicast, CGNAT
|
||||
* erlaubt – privates LAN (10/8, 172.16/12, 192.168/16, fc00::/7),
|
||||
* weil eine selbst gehostete Gitea-Instanz genau dort steht
|
||||
*
|
||||
* Ueber Umgebungsvariablen anpassbar:
|
||||
* GIT_ALLOW_LOOPBACK=true Gitea laeuft auf demselben Rechner
|
||||
* GIT_BLOCK_PRIVATE=true kein Zugriff ins private Netz
|
||||
* GIT_ALLOWED_HOSTS=a,b,c strengster Modus: nur diese Hosts
|
||||
*/
|
||||
|
||||
export class BlockedHostError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BlockedHostError";
|
||||
}
|
||||
}
|
||||
|
||||
function envFlag(name: string): boolean {
|
||||
return /^(1|true|yes|on)$/i.test(process.env[name]?.trim() ?? "");
|
||||
}
|
||||
|
||||
function allowlist(): string[] {
|
||||
return (process.env.GIT_ALLOWED_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ipv4Parts(address: string): number[] | null {
|
||||
const parts = address.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return null;
|
||||
return parts;
|
||||
}
|
||||
|
||||
type Verdict = { blocked: true; reason: string } | { blocked: false };
|
||||
|
||||
function classifyIpv4(address: string): Verdict {
|
||||
const parts = ipv4Parts(address);
|
||||
if (!parts) return { blocked: true, reason: "unlesbare IPv4-Adresse" };
|
||||
const [a, b] = parts;
|
||||
|
||||
if (a === 0) return { blocked: true, reason: "unspezifizierte Adresse (0.0.0.0/8)" };
|
||||
if (a === 127) {
|
||||
return envFlag("GIT_ALLOW_LOOPBACK")
|
||||
? { blocked: false }
|
||||
: { blocked: true, reason: "Loopback (127.0.0.0/8) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
|
||||
}
|
||||
if (a === 169 && b === 254) {
|
||||
return { blocked: true, reason: "Link-Local/Metadaten-Endpunkt (169.254.0.0/16)" };
|
||||
}
|
||||
if (a >= 224) return { blocked: true, reason: "Multicast oder reservierter Bereich" };
|
||||
if (a === 100 && b >= 64 && b <= 127) {
|
||||
return { blocked: true, reason: "Carrier-Grade-NAT (100.64.0.0/10)" };
|
||||
}
|
||||
|
||||
const isPrivate =
|
||||
a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
if (isPrivate && envFlag("GIT_BLOCK_PRIVATE")) {
|
||||
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
function classifyIpv6(address: string): Verdict {
|
||||
const value = address.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
|
||||
// IPv4-gemappte Adressen wie ::ffff:127.0.0.1 nach IPv4 weiterreichen.
|
||||
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (mapped) return classifyIpv4(mapped[1]);
|
||||
|
||||
if (value === "::" ) return { blocked: true, reason: "unspezifizierte Adresse (::)" };
|
||||
if (value === "::1") {
|
||||
return envFlag("GIT_ALLOW_LOOPBACK")
|
||||
? { blocked: false }
|
||||
: { blocked: true, reason: "Loopback (::1) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
|
||||
}
|
||||
if (value.startsWith("fe80")) return { blocked: true, reason: "Link-Local (fe80::/10)" };
|
||||
if (value.startsWith("ff")) return { blocked: true, reason: "Multicast (ff00::/8)" };
|
||||
|
||||
const isUniqueLocal = value.startsWith("fc") || value.startsWith("fd");
|
||||
if (isUniqueLocal && envFlag("GIT_BLOCK_PRIVATE")) {
|
||||
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
|
||||
}
|
||||
|
||||
return { blocked: false };
|
||||
}
|
||||
|
||||
function classifyAddress(address: string): Verdict {
|
||||
const version = isIP(address);
|
||||
if (version === 4) return classifyIpv4(address);
|
||||
if (version === 6) return classifyIpv6(address);
|
||||
return { blocked: true, reason: `keine gueltige IP-Adresse: ${address}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine URL, bevor der Server sie abruft. Wirft BlockedHostError,
|
||||
* wenn das Ziel nicht erlaubt ist.
|
||||
*/
|
||||
export async function assertAllowedUrl(rawUrl: string): Promise<URL> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new BlockedHostError(`Ungueltige URL: ${rawUrl}`);
|
||||
}
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new BlockedHostError(`Nur http und https sind erlaubt, nicht ${url.protocol}`);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new BlockedHostError("Zugangsdaten in der URL sind nicht erlaubt.");
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase();
|
||||
const allowed = allowlist();
|
||||
if (allowed.length > 0) {
|
||||
if (!allowed.includes(host)) {
|
||||
throw new BlockedHostError(
|
||||
`Host "${host}" steht nicht in GIT_ALLOWED_HOSTS.`,
|
||||
);
|
||||
}
|
||||
// Eine ausdrueckliche Freigabe schlaegt die Bereichspruefung.
|
||||
return url;
|
||||
}
|
||||
|
||||
// IP-Literal direkt pruefen, Namen erst aufloesen.
|
||||
if (isIP(host)) {
|
||||
const verdict = classifyAddress(host);
|
||||
if (verdict.blocked) {
|
||||
throw new BlockedHostError(`Zugriff auf ${host} blockiert: ${verdict.reason}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
let addresses: Array<{ address: string }>;
|
||||
try {
|
||||
addresses = await lookup(host, { all: true });
|
||||
} catch {
|
||||
throw new BlockedHostError(`Hostname "${host}" konnte nicht aufgeloest werden.`);
|
||||
}
|
||||
|
||||
if (addresses.length === 0) {
|
||||
throw new BlockedHostError(`Hostname "${host}" liefert keine Adresse.`);
|
||||
}
|
||||
|
||||
// Jede Antwort muss zulaessig sein, sonst greift DNS-Rebinding ueber einen
|
||||
// zweiten A-Record.
|
||||
for (const { address } of addresses) {
|
||||
const verdict = classifyAddress(address);
|
||||
if (verdict.blocked) {
|
||||
throw new BlockedHostError(
|
||||
`Zugriff auf ${host} (${address}) blockiert: ${verdict.reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -48,6 +48,88 @@ export const connectionCreateSchema = z.object({
|
||||
|
||||
export const connectionUpdateSchema = connectionCreateSchema.partial();
|
||||
|
||||
// ── Benutzer, Anmeldung, Verwaltung ──────────────────────────
|
||||
|
||||
/** Benutzernamen werden immer kleingeschrieben gespeichert und verglichen. */
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(3, "Mindestens 3 Zeichen")
|
||||
.max(32, "Hoechstens 32 Zeichen")
|
||||
.regex(
|
||||
/^[a-z0-9][a-z0-9._-]*$/,
|
||||
"Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich",
|
||||
);
|
||||
|
||||
const passwordField = z.string().min(1, "Passwort fehlt").max(200);
|
||||
const emailField = z.preprocess(
|
||||
emptyToNull,
|
||||
z.string().trim().toLowerCase().email("Keine gueltige E-Mail-Adresse").nullable().optional(),
|
||||
);
|
||||
const displayNameField = z.preprocess(
|
||||
emptyToNull,
|
||||
z.string().trim().max(80).nullable().optional(),
|
||||
);
|
||||
|
||||
export const setupSchema = z.object({
|
||||
username: usernameSchema,
|
||||
displayName: displayNameField,
|
||||
email: emailField,
|
||||
password: passwordField,
|
||||
/** Betriebsart, die beim Einrichten gewaehlt wurde. */
|
||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).default("SINGLE_USER"),
|
||||
});
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().trim().toLowerCase().min(1, "Benutzername fehlt").max(64),
|
||||
password: passwordField,
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
username: usernameSchema,
|
||||
displayName: displayNameField,
|
||||
email: emailField,
|
||||
password: passwordField,
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
/** Entfaellt bei Konten, die bisher nur einen Passkey haben. */
|
||||
currentPassword: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
|
||||
newPassword: passwordField,
|
||||
});
|
||||
|
||||
export const profileUpdateSchema = z.object({
|
||||
displayName: displayNameField,
|
||||
email: emailField,
|
||||
});
|
||||
|
||||
export const adminSettingsSchema = z.object({
|
||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
|
||||
allowRegistration: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const adminUserCreateSchema = z.object({
|
||||
username: usernameSchema,
|
||||
displayName: displayNameField,
|
||||
email: emailField,
|
||||
password: passwordField,
|
||||
role: z.enum(["ADMIN", "USER"]).default("USER"),
|
||||
});
|
||||
|
||||
export const adminUserUpdateSchema = z.object({
|
||||
displayName: displayNameField,
|
||||
email: emailField,
|
||||
role: z.enum(["ADMIN", "USER"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
/** Neues Passwort setzen; leer lassen heisst unveraendert. */
|
||||
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
|
||||
});
|
||||
|
||||
export const passkeyNameSchema = z.object({
|
||||
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
|
||||
});
|
||||
|
||||
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>;
|
||||
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
|
||||
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Erste Verteidigungslinie – laeuft vor jeder Anfrage.
|
||||
*
|
||||
* 1. CSRF: schreibende Anfragen muessen vom eigenen Origin kommen.
|
||||
* 2. Sicherheitskopfzeilen inklusive Content-Security-Policy mit Nonce.
|
||||
* 3. Grobe Zugangsschranke: ohne Sitzungscookie geht es zur Anmeldung.
|
||||
*
|
||||
* Wichtig: Punkt 3 ist nur Bequemlichkeit und Lastschutz, keine
|
||||
* Sicherheitsgrenze. Die Middleware laeuft im Edge-Runtime und kann das
|
||||
* Token nicht gegen die Datenbank pruefen. Verbindlich entschieden wird in
|
||||
* requireUserPage bzw. requireUserApi – also serverseitig in jeder Seite und
|
||||
* jedem Endpunkt.
|
||||
*/
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const SESSION_COOKIE = "nebula_session";
|
||||
|
||||
/** Ohne Anmeldung erreichbar. */
|
||||
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
||||
const PUBLIC_API_PREFIXES = ["/api/auth/"];
|
||||
/** Der Docker-Healthcheck hat kein Cookie. */
|
||||
const PUBLIC_API_EXACT = ["/api/health"];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
if (PUBLIC_PATHS.includes(pathname)) return true;
|
||||
if (PUBLIC_API_EXACT.includes(pathname)) return true;
|
||||
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicher Ursprung?
|
||||
*
|
||||
* Moderne Browser senden Sec-Fetch-Site bei jeder Anfrage und Origin bei
|
||||
* jeder schreibenden. Fehlen beide, stammt die Anfrage nicht aus einem
|
||||
* Browser (curl, Skript, Cronjob) und ist damit kein CSRF-Vektor – solche
|
||||
* Aufrufe bleiben erlaubt, damit die API skriptbar bleibt.
|
||||
*/
|
||||
function isSameOrigin(request: NextRequest): boolean {
|
||||
const site = request.headers.get("sec-fetch-site");
|
||||
if (site) return site === "same-origin" || site === "none";
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
return new URL(origin).host === request.headers.get("host");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildCsp(nonce: string, isDev: boolean): string {
|
||||
const scriptSrc = isDev
|
||||
? `'self' 'nonce-${nonce}' 'unsafe-eval' 'strict-dynamic'`
|
||||
: `'self' 'nonce-${nonce}' 'strict-dynamic'`;
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src ${scriptSrc}`,
|
||||
// Inline-Styles sind noetig: React setzt style-Attribute fuer die
|
||||
// Akzentfarben, Next liefert kritisches CSS inline aus.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
// Der Browser spricht nur mit der eigenen Instanz; die Git-APIs ruft
|
||||
// ausschliesslich der Server auf.
|
||||
"connect-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"manifest-src 'self'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// ── 1. CSRF ────────────────────────────────────────────────
|
||||
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Zugangsschranke ─────────────────────────────────────
|
||||
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (!hasSession && !isPublicPath(pathname)) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
const target = request.nextUrl.clone();
|
||||
target.pathname = "/login";
|
||||
target.search = "";
|
||||
// Nur den Pfad merken, keine absolute URL – sonst waere das ein
|
||||
// offener Redirect.
|
||||
if (pathname !== "/") target.searchParams.set("weiter", pathname);
|
||||
return NextResponse.redirect(target);
|
||||
}
|
||||
|
||||
// ── 3. Kopfzeilen ──────────────────────────────────────────
|
||||
const nonce = crypto.randomUUID().replace(/-/g, "");
|
||||
const csp = buildCsp(nonce, isDev);
|
||||
|
||||
// Next liest die Nonce aus der CSP der Anfrage und setzt sie auf seine
|
||||
// eigenen Skript-Tags.
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
requestHeaders.set("Content-Security-Policy", csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("Referrer-Policy", "same-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
);
|
||||
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
||||
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
|
||||
response.headers.set("X-DNS-Prefetch-Control", "off");
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Alles ausser den statischen Auslieferungen von Next. Die brauchen
|
||||
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
|
||||
*/
|
||||
{
|
||||
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)",
|
||||
missing: [
|
||||
{ type: "header", key: "next-router-prefetch" },
|
||||
{ type: "header", key: "purpose", value: "prefetch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user