Compare commits
35
Commits
dd0956ec71
..
v0.4.0
+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"
|
||||
|
||||
@@ -43,6 +43,9 @@ yarn-error.log*
|
||||
/data
|
||||
/postgres-data
|
||||
|
||||
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
|
||||
/backups
|
||||
|
||||
# editors
|
||||
.idea/
|
||||
.vscode/*
|
||||
|
||||
+147
-12
@@ -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
|
||||
@@ -126,6 +172,38 @@ Firmen-Gitea nebeneinander.
|
||||
|
||||
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
||||
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
|
||||
|
||||
### 4.4 Automatischer Abgleich
|
||||
|
||||
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
|
||||
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
|
||||
|
||||
| Einstellung | Bedeutung |
|
||||
| --- | --- |
|
||||
| Abstand | 15 Minuten bis täglich |
|
||||
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
|
||||
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
|
||||
|
||||
Wie der Durchlauf arbeitet:
|
||||
|
||||
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
|
||||
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
|
||||
Cron. Zwei Durchläufe überschneiden sich nie.
|
||||
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
|
||||
nächsten Mal.
|
||||
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
|
||||
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
|
||||
Token blockiert damit nicht dauerhaft das Kontingent.
|
||||
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
|
||||
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
|
||||
synchronisieren geht unabhängig davon immer.
|
||||
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
|
||||
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
|
||||
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||
|
||||
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
|
||||
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
||||
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
||||
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
||||
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
||||
@@ -137,6 +215,42 @@ Firmen-Gitea nebeneinander.
|
||||
|
||||
---
|
||||
|
||||
## 4a. Export, Import und Sicherung
|
||||
|
||||
### Projekte mitnehmen
|
||||
|
||||
- **JSON-Export** einzeln oder für alle Projekte: Stammdaten, Notizen, Tags,
|
||||
Repo-Verknüpfung. **Ohne Access Tokens** — die Verbindung wird nur beschrieben, das
|
||||
Token muss beim Import neu hinterlegt werden. Auch ohne Snapshots (reproduzierbar) und
|
||||
ohne Datenbank-IDs (gelten nur in der Quellinstanz).
|
||||
- **Markdown-Export** eines Projekts: Beschreibung, Kennzahlen, Notizen und der letzte
|
||||
bekannte Git-Stand als lesbare Datei.
|
||||
- **Import mit Vorschau.** Die Datei wird streng geprüft, bevor irgendetwas passiert.
|
||||
Abgeglichen wird über den Slug; bei Treffern stehen drei Strategien bereit:
|
||||
|
||||
| Strategie | Wirkung |
|
||||
| --- | --- |
|
||||
| Überspringen | Vorhandene bleiben unverändert |
|
||||
| Zusätzlich anlegen | Kopie mit freiem Slug (`nebula` → `nebula-2`) |
|
||||
| Ersetzen | Stammdaten überschreiben, Notizen austauschen |
|
||||
|
||||
Die Vorschau zeigt je Projekt, was geschähe, und weist auf fehlende Git-Verbindungen
|
||||
hin. Erst ein zweiter Klick schreibt.
|
||||
|
||||
### Sicherung
|
||||
|
||||
- **JSON-Sicherung** im Adminbereich: Einstellungen, Konten, Verbindungen, Projekte und
|
||||
Notizen in einer Datei. Passwörter als scrypt-Hash, Git-Tokens als Chiffrat — ohne
|
||||
dasselbe `APP_SECRET` wertlos. Sitzungen und Passkeys bleiben draußen: erstere sind
|
||||
kurzlebig, letztere hängen an Gerät und rpID.
|
||||
- **Datenbank-Sicherung** über `scripts/backup.*`: `pg_dump` im Container, mit
|
||||
`--clean --if-exists`, also für sich allein einspielbar. Gegenstück ist
|
||||
`scripts/restore.*`, das vor dem Überschreiben nachfragt und sich über einen Parameter
|
||||
auf eine Wegwerf-Datenbank umlenken lässt — damit sich eine Sicherung prüfen lässt,
|
||||
ohne die echte anzufassen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Oberfläche
|
||||
|
||||
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
|
||||
@@ -157,21 +271,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
|
||||
@@ -196,12 +338,5 @@ Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
|
||||
|
||||
## 7. Ideen für später
|
||||
|
||||
Bewusst nicht umgesetzt, aber vorgemerkt:
|
||||
|
||||
- Kanban-Board mit Drag & Drop zwischen den Statusspalten
|
||||
- Webhooks, damit Pushes den Snapshot ohne Knopfdruck aktualisieren
|
||||
- Markdown-Rendering in Beschreibung und Notizen
|
||||
- Volltextsuche über Notizen hinweg
|
||||
- Export eines Projekts als Markdown
|
||||
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
|
||||
- Mehrbenutzerbetrieb mit Login
|
||||
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
|
||||
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
|
||||
|
||||
@@ -22,10 +22,16 @@ 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.
|
||||
|
||||
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**.
|
||||
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
|
||||
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,21 +112,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:
|
||||
@@ -142,8 +182,80 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
||||
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
|
||||
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
|
||||
|
||||
### Automatisch statt auf Knopfdruck
|
||||
|
||||
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
|
||||
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
|
||||
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
|
||||
jeder Durchlauf API-Kontingent verbraucht.
|
||||
|
||||
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
|
||||
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
|
||||
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
|
||||
Projektdialog ausnehmen; manuell synchronisieren geht immer.
|
||||
|
||||
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
||||
|
||||
---
|
||||
|
||||
## Export, Import und Sicherung
|
||||
|
||||
### Projekte mitnehmen
|
||||
|
||||
| Weg | Wo | Inhalt |
|
||||
| --- | --- | --- |
|
||||
| **Alle Projekte als JSON** | Dashboard → Datenbank-Symbol | Stammdaten, Notizen, Repo-Verknüpfung |
|
||||
| **Ein Projekt als JSON** | Projektseite → Download-Symbol | dasselbe, für ein Projekt |
|
||||
| **Ein Projekt als Markdown** | Projektseite → Download-Symbol | lesbar, inklusive letztem Git-Stand |
|
||||
| **Import** | Dashboard → Datenbank-Symbol | JSON einlesen, mit Vorschau |
|
||||
|
||||
**Access Tokens sind nie enthalten.** Die Git-Verbindung wird nur beschrieben (Name,
|
||||
System, Basis-URL); nach dem Import muss das Token neu hinterlegt werden. Verweist eine
|
||||
importierte Datei auf eine hier unbekannte Verbindung, wird das Projekt ohne Verknüpfung
|
||||
angelegt und in der Vorschau angezeigt.
|
||||
|
||||
Beim Import wird über den Slug abgeglichen. Für Treffer gibt es drei Strategien:
|
||||
|
||||
- **Überspringen** — Vorhandene bleiben unverändert
|
||||
- **Zusätzlich anlegen** — Kopie mit freiem Slug (`nebula` → `nebula-2`)
|
||||
- **Ersetzen** — Stammdaten überschreiben, Notizen austauschen
|
||||
|
||||
Vor dem Schreiben zeigt eine Vorschau, was passieren würde. Erst dann wird geschrieben.
|
||||
|
||||
### Sicherung
|
||||
|
||||
Zwei Wege, mit unterschiedlichem Zweck:
|
||||
|
||||
**JSON-Sicherung** (Admin-Einstellungen → Sicherung) enthält Einstellungen, Konten,
|
||||
Verbindungen, Projekte und Notizen. Gut zum Umziehen. Passwörter liegen als scrypt-Hash
|
||||
vor, Git-Tokens als Chiffrat — ohne dasselbe `APP_SECRET` sind sie wertlos. Trotzdem
|
||||
vertraulich behandeln.
|
||||
|
||||
**Datenbank-Sicherung** über `pg_dump` — das, was man zum Wiederherstellen einer kaputten
|
||||
Instanz braucht:
|
||||
|
||||
```powershell
|
||||
.\scripts\backup.ps1 # → .\backups\nebula-<datum>.sql
|
||||
.\scripts\restore.ps1 -File .\backups\nebula-....sql
|
||||
```
|
||||
|
||||
```bash
|
||||
./scripts/backup.sh # → ./backups/nebula-<datum>.sql
|
||||
./scripts/restore.sh backups/nebula-....sql
|
||||
```
|
||||
|
||||
Die Skripte lesen Benutzer und Datenbankname aus der `.env` und führen `pg_dump` im
|
||||
Container aus — auf dem Rechner selbst muss nichts installiert sein.
|
||||
|
||||
> Eine Sicherung ist erst dann eine Sicherung, wenn du sie einmal zurückgespielt hast.
|
||||
> Das geht ohne Risiko in eine Wegwerf-Datenbank:
|
||||
>
|
||||
> ```powershell
|
||||
> docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
> .\scripts\restore.ps1 -File .\backups\nebula-....sql -Database nebula_test -Force
|
||||
> ```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
@@ -197,10 +309,29 @@ 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, Registrierung, Abgleich (Admin) |
|
||||
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (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` | `/api/projects/export` | Alle eigenen Projekte als JSON |
|
||||
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
|
||||
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
|
||||
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
|
||||
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
|
||||
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
|
||||
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
|
||||
@@ -220,16 +351,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 +395,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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Roadmap
|
||||
|
||||
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
|
||||
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
||||
|
||||
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
|
||||
|
||||
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
||||
|
||||
---
|
||||
|
||||
## 1 · Als Nächstes empfohlen
|
||||
|
||||
### 1.1 Markdown in Beschreibung und Notizen `S`
|
||||
|
||||
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
|
||||
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
|
||||
werden klickbar.
|
||||
|
||||
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
|
||||
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
|
||||
|
||||
### 1.2 Volltextsuche über Notizen `S`
|
||||
|
||||
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
|
||||
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
|
||||
zusätzlichen Dienst.
|
||||
|
||||
### 1.3 Kanban-Board mit Drag & Drop `M`
|
||||
|
||||
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
|
||||
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
|
||||
|
||||
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
|
||||
bleibt die Listenansicht.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Danach
|
||||
|
||||
### 2.1 Offene Pull Requests und Issues als Liste `S`
|
||||
|
||||
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
|
||||
nützlicher — die Daten holen wir ohnehin schon ab.
|
||||
|
||||
### 2.2 CI-Status je Branch `M`
|
||||
|
||||
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
|
||||
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
|
||||
Endpunkt und einen zusätzlichen Token-Scope.
|
||||
|
||||
### 2.3 Webhooks statt Polling `M`
|
||||
|
||||
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
||||
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
|
||||
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
|
||||
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
||||
|
||||
### 2.4 Commit-Aktivität als Verlauf `S`
|
||||
|
||||
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
|
||||
da, es fehlt nur die Darstellung.
|
||||
|
||||
### 2.5 Aufgaben und Checklisten `M`
|
||||
|
||||
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
|
||||
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
|
||||
|
||||
### 2.6 Benachrichtigungen `M`
|
||||
|
||||
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
|
||||
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
|
||||
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Größere Brocken
|
||||
|
||||
### 3.1 Automatisierte Tests und CI `L`
|
||||
|
||||
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
|
||||
Browser-Screenshots. Das gehört in eine Pipeline:
|
||||
|
||||
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
|
||||
Backoff-Berechnung
|
||||
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
|
||||
Datentrennung
|
||||
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
|
||||
|
||||
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
|
||||
länger lebt: er macht alles Weitere billiger und sicherer.
|
||||
|
||||
### 3.2 Mehrere Repositories je Projekt `M`
|
||||
|
||||
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischentabelle
|
||||
und Anpassungen in der Anzeige.
|
||||
|
||||
### 3.3 Zwei-Faktor per TOTP `M`
|
||||
|
||||
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
|
||||
erreichbar ist und nicht jedes Gerät Passkeys kann.
|
||||
|
||||
### 3.4 Passwort-Zurücksetzen per E-Mail `M`
|
||||
|
||||
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
||||
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
|
||||
|
||||
### 3.5 Mehrsprachigkeit `L`
|
||||
|
||||
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
|
||||
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
||||
|
||||
---
|
||||
|
||||
## 4 · Kleinigkeiten
|
||||
|
||||
| Idee | Aufwand | Nutzen |
|
||||
| --- | --- | --- |
|
||||
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
|
||||
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
|
||||
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
|
||||
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
|
||||
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
||||
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
||||
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
|
||||
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
|
||||
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 |
|
||||
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
|
||||
|
||||
---
|
||||
|
||||
## 5 · Bewusst nicht
|
||||
|
||||
Damit klar ist, was **nicht** kommt und warum:
|
||||
|
||||
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
||||
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
||||
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
|
||||
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
|
||||
Übersicht, keine Entwicklungsumgebung.
|
||||
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
|
||||
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
|
||||
der Punkt.
|
||||
|
||||
---
|
||||
|
||||
## Erledigt
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
|
||||
| GitHub, GitLab und Gitea | ✓ |
|
||||
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
|
||||
| Einzel- und Mehrbenutzerbetrieb | ✓ |
|
||||
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
||||
| Docker- und Standalone-Betrieb | ✓ |
|
||||
| Automatischer Abgleich mit Zeitplan | ✓ |
|
||||
| Export, Import und Sicherung | ✓ |
|
||||
|
||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||
ebenfalls draußen — sie sind reproduzierbar. Der Projekt-Export steht jedem Konto für
|
||||
seine eigenen Daten offen, die Voll-Sicherung nur Administratoren.
|
||||
@@ -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
+2723
-3
File diff suppressed because it is too large
Load Diff
+14
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||
"scripts": {
|
||||
@@ -23,17 +23,27 @@
|
||||
"docker:logs": "docker compose --profile full logs -f app",
|
||||
"docker:tools": "docker compose --profile tools up -d",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"postinstall": "prisma generate"
|
||||
"postinstall": "prisma generate",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@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",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "^3.25.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -44,7 +54,8 @@
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.11"
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
|
||||
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
|
||||
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
|
||||
ADD COLUMN "lastAutoSyncReport" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
|
||||
+148
-2
@@ -33,6 +33,131 @@ 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")
|
||||
|
||||
// ── Automatischer Abgleich mit den Git-Systemen ────────────
|
||||
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
|
||||
/// das soll eine bewusste Entscheidung sein.
|
||||
autoSyncEnabled Boolean @default(false)
|
||||
/// Abstand zwischen zwei Durchlaeufen in Minuten.
|
||||
autoSyncIntervalMinutes Int @default(60)
|
||||
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
|
||||
/// noch etwas.
|
||||
autoSyncActiveOnly Boolean @default(true)
|
||||
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
|
||||
/// einen Schlag in ein Rate-Limit laeuft.
|
||||
autoSyncBatchSize Int @default(25)
|
||||
|
||||
lastAutoSyncAt DateTime?
|
||||
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
||||
lastAutoSyncReport Json?
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||
model GitConnection {
|
||||
id String @id @default(cuid())
|
||||
@@ -53,20 +178,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).
|
||||
@@ -92,6 +225,17 @@ model Project {
|
||||
|
||||
lastSyncedAt DateTime?
|
||||
syncError String?
|
||||
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
||||
/// einem Repo, dessen Token staendig Fehler wirft.
|
||||
autoSync Boolean @default(true)
|
||||
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
|
||||
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
||||
syncFailureCount Int @default(0)
|
||||
|
||||
/// 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?
|
||||
@@ -99,9 +243,11 @@ model Project {
|
||||
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,64 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||
|
||||
.DESCRIPTION
|
||||
Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||
Datenbanksicherung: Schema, Daten, Sequenzen, Indizes. Genau das, was
|
||||
man zum Wiederherstellen einer kaputten Instanz braucht.
|
||||
|
||||
Laeuft die Datenbank im mitgelieferten Container, wird pg_dump dort
|
||||
ausgefuehrt - auf dem Rechner selbst muss dann nichts installiert sein.
|
||||
|
||||
.PARAMETER OutDir
|
||||
Zielverzeichnis. Vorgabe: .\backups
|
||||
|
||||
.PARAMETER Container
|
||||
Name des Postgres-Containers. Vorgabe: nebula-db
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\backup.ps1
|
||||
.\scripts\backup.ps1 -OutDir D:\Sicherungen
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutDir = (Join-Path $PSScriptRoot "..\backups"),
|
||||
[string]$Container = "nebula-db"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# .env einlesen, damit Benutzer und Datenbankname nicht doppelt gepflegt werden.
|
||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||
if (Test-Path $envFile) {
|
||||
foreach ($line in Get-Content $envFile) {
|
||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = $settings["POSTGRES_USER"]
|
||||
$database = $settings["POSTGRES_DB"]
|
||||
|
||||
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
||||
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd-HHmmss"
|
||||
$target = Join-Path $OutDir "nebula-$stamp.sql"
|
||||
|
||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||
if ($running -notcontains $Container) {
|
||||
Write-Error "Container '$Container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren."
|
||||
}
|
||||
|
||||
Write-Host "Sichere Datenbank '$database' aus Container '$Container'..."
|
||||
|
||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||
docker exec $Container pg_dump -U $user -d $database --clean --if-exists | Out-File -Encoding utf8 $target
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "pg_dump ist fehlgeschlagen." }
|
||||
|
||||
$size = [math]::Round((Get-Item $target).Length / 1KB, 1)
|
||||
Write-Host "Fertig: $target ($size KB)" -ForegroundColor Green
|
||||
Write-Host "Wiederherstellen mit: .\scripts\restore.ps1 -File `"$target`""
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Byteweise Sicherung der Nebula-Datenbank mit pg_dump.
|
||||
#
|
||||
# Ergaenzt den JSON-Export im Adminbereich um eine vollstaendige
|
||||
# Datenbanksicherung: Schema, Daten, Sequenzen, Indizes.
|
||||
#
|
||||
# Aufruf: ./scripts/backup.sh [Zielverzeichnis]
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -eu
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
out_dir="${1:-$root/backups}"
|
||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||
|
||||
user="nebula"
|
||||
database="nebula"
|
||||
if [ -f "$root/.env" ]; then
|
||||
# Nur die beiden benoetigten Werte lesen, ohne die Datei auszufuehren.
|
||||
value_of() {
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||
"$root/.env" | tail -n 1
|
||||
}
|
||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||
fi
|
||||
|
||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||
echo "Container '$container' laeuft nicht. Zuerst 'docker compose up -d db' ausfuehren." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
stamp="$(date +%Y-%m-%d-%H%M%S)"
|
||||
target="$out_dir/nebula-$stamp.sql"
|
||||
|
||||
echo "Sichere Datenbank '$database' aus Container '$container'..."
|
||||
# --clean --if-exists macht die Datei fuer sich allein einspielbar.
|
||||
docker exec "$container" pg_dump -U "$user" -d "$database" --clean --if-exists > "$target"
|
||||
|
||||
size="$(du -h "$target" | cut -f1)"
|
||||
echo "Fertig: $target ($size)"
|
||||
echo "Wiederherstellen mit: ./scripts/restore.sh \"$target\""
|
||||
@@ -0,0 +1,62 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Spielt eine mit backup.ps1 erzeugte Sicherung zurueck.
|
||||
|
||||
.DESCRIPTION
|
||||
ACHTUNG: Der aktuelle Inhalt der Datenbank wird dabei ersetzt. Das Skript
|
||||
fragt vorher nach, es sei denn, -Force ist gesetzt.
|
||||
|
||||
.PARAMETER Database
|
||||
Zieldatenbank. Vorgabe: der Wert aus der .env.
|
||||
|
||||
Tipp: Eine Sicherung laesst sich pruefen, ohne die echte Datenbank
|
||||
anzufassen - in eine leere Testdatenbank einspielen und nachsehen:
|
||||
|
||||
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
.\scripts\restore.ps1 -File .\backups\... -Database nebula_test -Force
|
||||
|
||||
.EXAMPLE
|
||||
.\scripts\restore.ps1 -File .\backups\nebula-2026-07-26-101500.sql
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$File,
|
||||
[string]$Database,
|
||||
[string]$Container = "nebula-db",
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $File)) { Write-Error "Datei nicht gefunden: $File" }
|
||||
|
||||
$envFile = Join-Path $PSScriptRoot "..\.env"
|
||||
$settings = @{ POSTGRES_USER = "nebula"; POSTGRES_DB = "nebula" }
|
||||
if (Test-Path $envFile) {
|
||||
foreach ($line in Get-Content $envFile) {
|
||||
if ($line -match '^\s*([A-Z_]+)\s*=\s*"?([^"#]*)"?\s*$') {
|
||||
$settings[$Matches[1]] = $Matches[2].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = $settings["POSTGRES_USER"]
|
||||
$database = if ($Database) { $Database } else { $settings["POSTGRES_DB"] }
|
||||
|
||||
if (-not $Force) {
|
||||
Write-Host "Der gesamte Inhalt der Datenbank '$database' wird ersetzt." -ForegroundColor Yellow
|
||||
$answer = Read-Host "Wirklich fortfahren? (ja/nein)"
|
||||
if ($answer -ne "ja") { Write-Host "Abgebrochen."; exit 0 }
|
||||
}
|
||||
|
||||
$running = docker ps --filter "name=$Container" --format "{{.Names}}"
|
||||
if ($running -notcontains $Container) {
|
||||
Write-Error "Container '$Container' laeuft nicht."
|
||||
}
|
||||
|
||||
Write-Host "Spiele '$File' ein..."
|
||||
Get-Content $File -Raw | docker exec -i $Container psql -U $user -d $database -v ON_ERROR_STOP=1
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Write-Error "Das Einspielen ist fehlgeschlagen." }
|
||||
|
||||
Write-Host "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut." -ForegroundColor Green
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Spielt eine mit backup.sh erzeugte Sicherung zurueck.
|
||||
#
|
||||
# ACHTUNG: Der aktuelle Inhalt der Datenbank wird ersetzt.
|
||||
#
|
||||
# Aufruf: ./scripts/restore.sh <datei.sql> [--force]
|
||||
#
|
||||
# Zieldatenbank ueberschreiben mit NEBULA_DB_NAME. Damit laesst sich eine
|
||||
# Sicherung pruefen, ohne die echte Datenbank anzufassen:
|
||||
#
|
||||
# docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
|
||||
# NEBULA_DB_NAME=nebula_test ./scripts/restore.sh backups/... --force
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -eu
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
file="${1:-}"
|
||||
force="${2:-}"
|
||||
container="${NEBULA_DB_CONTAINER:-nebula-db}"
|
||||
|
||||
if [ -z "$file" ]; then
|
||||
echo "Aufruf: ./scripts/restore.sh <datei.sql> [--force]" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Datei nicht gefunden: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
user="nebula"
|
||||
database="nebula"
|
||||
if [ -f "$root/.env" ]; then
|
||||
value_of() {
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*\"\{0,1\}\([^\"#]*\)\"\{0,1\}[[:space:]]*$/\1/p" \
|
||||
"$root/.env" | tail -n 1
|
||||
}
|
||||
[ -n "$(value_of POSTGRES_USER)" ] && user="$(value_of POSTGRES_USER)"
|
||||
[ -n "$(value_of POSTGRES_DB)" ] && database="$(value_of POSTGRES_DB)"
|
||||
fi
|
||||
database="${NEBULA_DB_NAME:-$database}"
|
||||
|
||||
if [ "$force" != "--force" ]; then
|
||||
printf "Der gesamte Inhalt der Datenbank '%s' wird ersetzt.\nWirklich fortfahren? (ja/nein) " "$database"
|
||||
read -r answer
|
||||
[ "$answer" = "ja" ] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
|
||||
if ! docker ps --filter "name=$container" --format '{{.Names}}' | grep -qx "$container"; then
|
||||
echo "Container '$container' laeuft nicht." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Spiele '$file' ein..."
|
||||
docker exec -i "$container" psql -U "$user" -d "$database" -v ON_ERROR_STOP=1 < "$file"
|
||||
|
||||
echo "Fertig. Anwendung neu starten, damit sie eine frische Verbindung aufbaut."
|
||||
@@ -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,76 @@
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
AdminManager,
|
||||
type AdminData,
|
||||
type AutoSyncReport,
|
||||
} 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, eligible] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.findMany({
|
||||
select: userSafeSelect,
|
||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
||||
}),
|
||||
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
||||
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
||||
prisma.project.count({
|
||||
where: {
|
||||
autoSync: true,
|
||||
OR: [
|
||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||
{ repoUrl: { not: null } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data: AdminData = {
|
||||
mode: settings.mode,
|
||||
allowRegistration: settings.allowRegistration,
|
||||
users,
|
||||
currentUserId: admin.id,
|
||||
appUrl: APP_URL,
|
||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||
autoSync: {
|
||||
enabled: settings.autoSyncEnabled,
|
||||
intervalMinutes: settings.autoSyncIntervalMinutes,
|
||||
activeOnly: settings.autoSyncActiveOnly,
|
||||
batchSize: settings.autoSyncBatchSize,
|
||||
lastRunAt: settings.lastAutoSyncAt,
|
||||
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
|
||||
eligible,
|
||||
},
|
||||
};
|
||||
|
||||
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, automatischer Abgleich 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,88 @@
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Vollstaendige Sicherung der Instanz als JSON.
|
||||
*
|
||||
* Anders als der Projekt-Export ist das eine echte Sicherung und enthaelt
|
||||
* deshalb auch Konten und Verbindungen. Was drinsteht:
|
||||
*
|
||||
* - Passwoerter nur als scrypt-Hash, nicht im Klartext
|
||||
* - Git-Tokens nur als Chiffrat; ohne dasselbe APP_SECRET sind sie wertlos
|
||||
* - Sitzungen und Passkeys bewusst nicht: Sitzungen sind kurzlebig, und
|
||||
* Passkeys haengen ohnehin am Geraet und an der rpID
|
||||
*
|
||||
* Die Datei ist damit vertraulich zu behandeln. Fuer eine byteweise Sicherung
|
||||
* der Datenbank siehe scripts/backup.* mit pg_dump.
|
||||
*/
|
||||
export async function GET() {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const [settings, users, connections, projects] = await Promise.all([
|
||||
prisma.appSettings.findUnique({ where: { id: "singleton" } }),
|
||||
prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
orderBy: { username: "asc" },
|
||||
}),
|
||||
prisma.gitConnection.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
name: true,
|
||||
provider: true,
|
||||
baseUrl: true,
|
||||
username: true,
|
||||
tokenCipher: true,
|
||||
tokenHint: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
include: {
|
||||
notes: { orderBy: { createdAt: "asc" } },
|
||||
// Snapshots bleiben draussen: reproduzierbar und gross.
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const payload = {
|
||||
kind: "nebula.backup",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: APP_NAME,
|
||||
note:
|
||||
"Enthaelt Passwort-Hashes und verschluesselte Git-Tokens. " +
|
||||
"Die Tokens sind nur mit demselben APP_SECRET wieder lesbar. Vertraulich behandeln.",
|
||||
settings,
|
||||
users,
|
||||
connections,
|
||||
projects,
|
||||
};
|
||||
|
||||
const filename = exportFileName(`${APP_NAME}-sicherung`, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
autoSyncEnabled,
|
||||
autoSyncIntervalMinutes,
|
||||
autoSyncActiveOnly,
|
||||
autoSyncBatchSize,
|
||||
} = 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 autoSyncFields = {
|
||||
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
||||
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
|
||||
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
|
||||
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
|
||||
};
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
...autoSyncFields,
|
||||
},
|
||||
update: {
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||
...autoSyncFields,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ settings });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminApi } from "@/lib/auth/guard";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
||||
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
|
||||
*
|
||||
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
|
||||
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
|
||||
* eingebauten Automatismus zu nutzen.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireAdminApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`sync-run:${clientIp(request)}`,
|
||||
LIMITS.outbound.limit,
|
||||
LIMITS.outbound.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
if (isAutoSyncRunning()) {
|
||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||
}
|
||||
|
||||
const report = await runAutoSyncPass("manuell");
|
||||
if (!report) {
|
||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
||||
}
|
||||
|
||||
return NextResponse.json({ report });
|
||||
}
|
||||
@@ -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,88 @@
|
||||
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",
|
||||
// Die Registrierung meldet direkt an.
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
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,78 @@
|
||||
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",
|
||||
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
||||
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
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 {
|
||||
if (data.name !== undefined) {
|
||||
const duplicate = await prisma.gitConnection.findFirst({
|
||||
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
|
||||
select: { id: true },
|
||||
});
|
||||
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 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden oder Name vergeben" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
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 } });
|
||||
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 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +35,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const note = await prisma.note.update({
|
||||
const updated = await prisma.note.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
|
||||
@@ -33,18 +43,20 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ note });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
|
||||
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 } });
|
||||
|
||||
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 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
import { buildExportFile, toMarkdown } from "@/lib/export/project";
|
||||
import { jsonError } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
/**
|
||||
* Ein einzelnes Projekt exportieren.
|
||||
*
|
||||
* ?format=json (Vorgabe) – zum Wiedereinlesen
|
||||
* ?format=md – lesbar, zum Archivieren
|
||||
*/
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const { id } = await params;
|
||||
const format = new URL(request.url).searchParams.get("format") ?? "json";
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: { id, ownerId: auth.user.id },
|
||||
include: {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||
snapshot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||||
|
||||
if (format === "md" || format === "markdown") {
|
||||
const filename = exportFileName(project.slug, new Date(), "md");
|
||||
return new Response(toMarkdown(project), {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const payload = buildExportFile([project]);
|
||||
const filename = exportFileName(project.slug, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,39 @@ 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",
|
||||
"autoSync",
|
||||
"connectionId",
|
||||
] as const) {
|
||||
if (data[key] !== undefined) update[key] = data[key];
|
||||
}
|
||||
|
||||
@@ -79,11 +104,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 } });
|
||||
|
||||
// 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 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { exportFileName } from "@/lib/export/format";
|
||||
import { buildExportFile, loadProjectsForExport } from "@/lib/export/project";
|
||||
import { jsonError } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Alle eigenen Projekte als JSON zum Herunterladen. */
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const projects = await loadProjectsForExport(auth.user.id);
|
||||
if (projects.length === 0) {
|
||||
return jsonError("Es gibt noch keine Projekte zum Exportieren.", 404);
|
||||
}
|
||||
|
||||
const payload = buildExportFile(projects);
|
||||
const filename = exportFileName(`${APP_NAME}-projekte`, new Date(), "json");
|
||||
|
||||
return new Response(JSON.stringify(payload, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
|
||||
import { uniqueSlug } from "@/lib/projects";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import {
|
||||
IMPORT_MAX_BODY_BYTES,
|
||||
clientIp,
|
||||
jsonError,
|
||||
readJsonBody,
|
||||
tooManyRequests,
|
||||
} from "@/lib/security/request";
|
||||
import { slugify } from "@/lib/utils";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestSchema = z.object({
|
||||
data: z.unknown(),
|
||||
/**
|
||||
* skip – vorhandene Projekte unveraendert lassen
|
||||
* rename – zusaetzlich anlegen, mit freiem Slug
|
||||
* overwrite – vorhandenes Projekt ersetzen (Notizen werden ausgetauscht)
|
||||
*/
|
||||
strategy: z.enum(["skip", "rename", "overwrite"]).default("skip"),
|
||||
/** Nur pruefen und berichten, nichts schreiben. */
|
||||
dryRun: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type Action = "create" | "rename" | "overwrite" | "skip" | "error";
|
||||
|
||||
interface ItemResult {
|
||||
name: string;
|
||||
slug: string;
|
||||
action: Action;
|
||||
/** Bei "rename" der tatsaechlich vergebene Slug. */
|
||||
newSlug?: string;
|
||||
notes: number;
|
||||
/** matched: Verbindung gefunden · missing: beschrieben, aber nicht vorhanden */
|
||||
connection: "matched" | "missing" | "none";
|
||||
connectionName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const limited = rateLimit(
|
||||
`import:${clientIp(request)}`,
|
||||
LIMITS.mutation.limit,
|
||||
LIMITS.mutation.windowMs,
|
||||
);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request, IMPORT_MAX_BODY_BYTES);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const outer = requestSchema.safeParse(body.data);
|
||||
if (!outer.success) return jsonError("Ungueltige Anfrage.", 422);
|
||||
|
||||
// Die hochgeladene Datei ist Fremdinhalt – strikt pruefen, bevor irgendetwas
|
||||
// damit passiert.
|
||||
const parsed = exportFileSchema.safeParse(outer.data.data);
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0];
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Die Datei passt nicht zum erwarteten Format. " +
|
||||
(first ? `${first.path.join(".") || "Datei"}: ${first.message}` : ""),
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const { strategy, dryRun } = outer.data;
|
||||
const file = parsed.data;
|
||||
|
||||
// Verbindungen des Kontos einmal laden, um sie den Projekten zuzuordnen.
|
||||
const connections = await prisma.gitConnection.findMany({
|
||||
where: { ownerId: auth.user.id },
|
||||
select: { id: true, name: true, provider: true, baseUrl: true },
|
||||
});
|
||||
|
||||
function matchConnection(described: ExportedProject["connection"]) {
|
||||
if (!described) return null;
|
||||
const byName = connections.find(
|
||||
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
|
||||
);
|
||||
if (byName) return byName;
|
||||
return (
|
||||
connections.find(
|
||||
(candidate) =>
|
||||
candidate.provider === described.provider &&
|
||||
candidate.baseUrl.replace(/\/+$/, "").toLowerCase() ===
|
||||
described.baseUrl.replace(/\/+$/, "").toLowerCase(),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const results: ItemResult[] = [];
|
||||
|
||||
for (const incoming of file.projects) {
|
||||
const slug = incoming.slug || slugify(incoming.name) || "projekt";
|
||||
const connection = matchConnection(incoming.connection);
|
||||
const connectionState: ItemResult["connection"] = incoming.connection
|
||||
? connection
|
||||
? "matched"
|
||||
: "missing"
|
||||
: "none";
|
||||
|
||||
const existing = await prisma.project.findFirst({
|
||||
where: { slug, ownerId: auth.user.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const base: ItemResult = {
|
||||
name: incoming.name,
|
||||
slug,
|
||||
action: "create",
|
||||
notes: incoming.notes.length,
|
||||
connection: connectionState,
|
||||
connectionName: incoming.connection?.name,
|
||||
};
|
||||
|
||||
if (existing && strategy === "skip") {
|
||||
results.push({ ...base, action: "skip", message: "Slug bereits vergeben." });
|
||||
continue;
|
||||
}
|
||||
|
||||
const action: Action = !existing ? "create" : strategy === "rename" ? "rename" : "overwrite";
|
||||
|
||||
if (dryRun) {
|
||||
results.push({
|
||||
...base,
|
||||
action,
|
||||
...(action === "rename" ? { newSlug: `${slug}-…` } : {}),
|
||||
...(action === "overwrite"
|
||||
? { message: "Ersetzt Stammdaten und tauscht die Notizen aus." }
|
||||
: {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: incoming.name,
|
||||
summary: incoming.summary ?? null,
|
||||
description: incoming.description ?? null,
|
||||
status: incoming.status,
|
||||
priority: incoming.priority,
|
||||
progress: incoming.progress,
|
||||
accent: incoming.accent,
|
||||
tags: incoming.tags,
|
||||
favorite: incoming.favorite,
|
||||
autoSync: incoming.autoSync,
|
||||
repoUrl: incoming.repoUrl ?? null,
|
||||
repoOwner: incoming.repoOwner ?? null,
|
||||
repoName: incoming.repoName ?? null,
|
||||
connectionId: connection?.id ?? null,
|
||||
};
|
||||
|
||||
const notes = incoming.notes.map((note) => ({
|
||||
title: note.title ?? null,
|
||||
content: note.content,
|
||||
pinned: note.pinned ?? false,
|
||||
...(note.createdAt && !Number.isNaN(Date.parse(note.createdAt))
|
||||
? { createdAt: new Date(note.createdAt) }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
try {
|
||||
if (existing && action === "overwrite") {
|
||||
// Notizen austauschen statt zusammenfuehren: vorhersagbar, und ein
|
||||
// erneuter Import erzeugt keine Dubletten.
|
||||
await prisma.$transaction([
|
||||
prisma.note.deleteMany({ where: { projectId: existing.id } }),
|
||||
prisma.project.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...data, slug, notes: { create: notes } },
|
||||
}),
|
||||
]);
|
||||
results.push({ ...base, action: "overwrite" });
|
||||
} else {
|
||||
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
...data,
|
||||
ownerId: auth.user.id,
|
||||
slug: finalSlug,
|
||||
notes: { create: notes },
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
...base,
|
||||
action: existing ? "rename" : "create",
|
||||
...(existing ? { newSlug: finalSlug } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
...base,
|
||||
action: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
created: results.filter((entry) => entry.action === "create").length,
|
||||
renamed: results.filter((entry) => entry.action === "rename").length,
|
||||
overwritten: results.filter((entry) => entry.action === "overwrite").length,
|
||||
skipped: results.filter((entry) => entry.action === "skip").length,
|
||||
errors: results.filter((entry) => entry.action === "error").length,
|
||||
missingConnections: results.filter((entry) => entry.connection === "missing").length,
|
||||
};
|
||||
|
||||
return NextResponse.json({ dryRun, strategy, summary, results });
|
||||
}
|
||||
@@ -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,
|
||||
@@ -72,6 +96,7 @@ export async function POST(request: Request) {
|
||||
accent: data.accent,
|
||||
tags: data.tags,
|
||||
favorite: data.favorite,
|
||||
autoSync: data.autoSync,
|
||||
repoUrl: data.repoUrl ?? null,
|
||||
repoOwner,
|
||||
repoName,
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge.
|
||||
*
|
||||
* Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal
|
||||
* was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter
|
||||
* durch. backdrop-filter hilft hier nicht: liegt das Element in einem
|
||||
* Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die
|
||||
* Weichzeichnung wirkungslos.
|
||||
*/
|
||||
.popover {
|
||||
/* Vollstaendig deckend: bei 97 % schimmerte darunterliegender Text
|
||||
(die Schaltflaechen "Bearbeiten" und "Loeschen") noch durch. */
|
||||
background: #090b18;
|
||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
||||
border-radius: 0.9rem;
|
||||
box-shadow:
|
||||
0 1px 0 0 rgba(255, 255, 255, 0.07) inset,
|
||||
0 28px 70px -24px rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-Inhalte. Tailwind setzt in seinem Preflight alle Abstaende und
|
||||
* Schriftgroessen zurueck, also muessen Ueberschriften, Listen und
|
||||
* Codebloecke hier wieder Form bekommen.
|
||||
*/
|
||||
.md {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.65;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.md > *:first-child { margin-top: 0; }
|
||||
.md > *:last-child { margin-bottom: 0; }
|
||||
|
||||
.md h1, .md h2, .md h3, .md h4 {
|
||||
color: #f1f5f9;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 1.4em 0 0.5em;
|
||||
}
|
||||
.md h1 { font-size: 1.3rem; }
|
||||
.md h2 { font-size: 1.15rem; }
|
||||
.md h3 { font-size: 1rem; }
|
||||
.md h4 { font-size: 0.9rem; }
|
||||
|
||||
.md p { margin: 0.75em 0; }
|
||||
|
||||
.md a {
|
||||
color: #c4b5fd;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.md a:hover { color: #ddd6fe; }
|
||||
|
||||
.md strong { color: #f1f5f9; font-weight: 600; }
|
||||
|
||||
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
|
||||
.md ul { list-style: disc; }
|
||||
.md ol { list-style: decimal; }
|
||||
.md li { margin: 0.3em 0; }
|
||||
.md li::marker { color: #64748b; }
|
||||
|
||||
/* Aufgabenlisten aus "- [ ] …" */
|
||||
.md li:has(> input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
margin-left: -1.35em;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.md input[type="checkbox"] {
|
||||
margin-top: 0.35em;
|
||||
accent-color: #8b5cf6;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.md code {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: #e9d5ff;
|
||||
}
|
||||
|
||||
.md pre {
|
||||
background: rgba(4, 6, 16, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0.9em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.md blockquote {
|
||||
border-left: 2px solid rgba(167, 139, 250, 0.5);
|
||||
padding-left: 0.9rem;
|
||||
margin: 0.9em 0;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.md hr {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||
margin: 1.4em 0;
|
||||
}
|
||||
|
||||
.md table {
|
||||
width: 100%;
|
||||
margin: 0.9em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md th, .md td {
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
padding: 0.4rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
.md th {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e2e8f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.md img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schieberegler. Der native Regler zeichnet die Spur rechts vom Griff in
|
||||
* einem hellen Standardton - im dunklen Layout ein grelles Band. Deshalb
|
||||
* vollstaendig selbst gezeichnet: der gefuellte Anteil kommt aus der
|
||||
* Variablen --fill, die die Komponente aus dem Wert setzt.
|
||||
*/
|
||||
.range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#8b5cf6 0%,
|
||||
#d946ef var(--fill, 0%),
|
||||
rgba(255, 255, 255, 0.1) var(--fill, 0%),
|
||||
rgba(255, 255, 255, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.range:focus-visible {
|
||||
outline: 2px solid rgba(167, 139, 250, 0.8);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 9999px;
|
||||
background: #f5f3ff;
|
||||
border: 2px solid #7c3aed;
|
||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.range::-webkit-slider-thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.range::-moz-range-thumb {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
border-radius: 9999px;
|
||||
background: #f5f3ff;
|
||||
border: 2px solid #7c3aed;
|
||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.range::-moz-range-track {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
+41
-199
@@ -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);
|
||||
}
|
||||
|
||||
* {
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
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,
|
||||
|
||||
+21
-11
@@ -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 = {
|
||||
/**
|
||||
* 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: "Nebula – Vibe Coding Dashboard",
|
||||
template: "%s · Nebula",
|
||||
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: "Nebula",
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
+8
-1
@@ -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" },
|
||||
}),
|
||||
@@ -58,7 +63,9 @@ export default async function DashboardPage() {
|
||||
<StatCard
|
||||
label="Ideen"
|
||||
value={ideas}
|
||||
hint={STATUS_META.IDEA.description}
|
||||
// Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
|
||||
// der schmalen Kachel mitten im Wort abgeschnitten.
|
||||
hint="Noch nichts gebaut"
|
||||
icon={Lightbulb}
|
||||
color={STATUS_META.IDEA.color}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,11 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
import { GitPanel } from "@/components/GitPanel";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
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 +16,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 +31,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();
|
||||
@@ -51,9 +67,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
||||
Beschreibung
|
||||
</h2>
|
||||
{detail.description ? (
|
||||
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
|
||||
{detail.description}
|
||||
</p>
|
||||
<Markdown className="mt-3">{detail.description}</Markdown>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-slate-500">
|
||||
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileJson,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Strategy = "skip" | "rename" | "overwrite";
|
||||
|
||||
interface ItemResult {
|
||||
name: string;
|
||||
slug: string;
|
||||
action: "create" | "rename" | "overwrite" | "skip" | "error";
|
||||
newSlug?: string;
|
||||
notes: number;
|
||||
connection: "matched" | "missing" | "none";
|
||||
connectionName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ImportResponse {
|
||||
dryRun: boolean;
|
||||
strategy: Strategy;
|
||||
summary: {
|
||||
total: number;
|
||||
created: number;
|
||||
renamed: number;
|
||||
overwritten: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
missingConnections: number;
|
||||
};
|
||||
results: ItemResult[];
|
||||
}
|
||||
|
||||
const STRATEGIES: Array<{ value: Strategy; label: string; hint: string }> = [
|
||||
{ value: "skip", label: "Ueberspringen", hint: "Vorhandene bleiben unveraendert." },
|
||||
{ value: "rename", label: "Zusaetzlich anlegen", hint: "Doppelte bekommen einen freien Slug." },
|
||||
{ value: "overwrite", label: "Ersetzen", hint: "Stammdaten und Notizen werden ueberschrieben." },
|
||||
];
|
||||
|
||||
const ACTION_LABEL: Record<ItemResult["action"], { text: string; className: string }> = {
|
||||
create: { text: "neu", className: "bg-emerald-500/12 text-emerald-200" },
|
||||
rename: { text: "zusaetzlich", className: "bg-cyan-500/12 text-cyan-200" },
|
||||
overwrite: { text: "ersetzt", className: "bg-amber-500/12 text-amber-200" },
|
||||
skip: { text: "uebersprungen", className: "bg-slate-500/12 text-slate-300" },
|
||||
error: { text: "Fehler", className: "bg-rose-500/12 text-rose-200" },
|
||||
};
|
||||
|
||||
export function ImportDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [payload, setPayload] = useState<unknown>(null);
|
||||
const [strategy, setStrategy] = useState<Strategy>("skip");
|
||||
const [preview, setPreview] = useState<ImportResponse | null>(null);
|
||||
const [done, setDone] = useState<ImportResponse | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setFileName(null);
|
||||
setPayload(null);
|
||||
setPreview(null);
|
||||
setDone(null);
|
||||
setError(null);
|
||||
setStrategy("skip");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
function close() {
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function pickFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
setDone(null);
|
||||
setFileName(file.name);
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
setPayload(parsed);
|
||||
await run(parsed, strategy, true);
|
||||
} catch {
|
||||
setPayload(null);
|
||||
setError("Die Datei enthaelt kein gueltiges JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
async function run(data: unknown, chosen: Strategy, dryRun: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/projects/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data, strategy: chosen, dryRun }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error ?? "Import fehlgeschlagen.");
|
||||
|
||||
if (dryRun) setPreview(body as ImportResponse);
|
||||
else {
|
||||
setDone(body as ImportResponse);
|
||||
router.refresh();
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStrategy(next: Strategy) {
|
||||
setStrategy(next);
|
||||
if (payload) await run(payload, next, true);
|
||||
}
|
||||
|
||||
const summary = done?.summary ?? preview?.summary;
|
||||
const results = done?.results ?? preview?.results ?? [];
|
||||
|
||||
// Wuerde der Import ueberhaupt etwas veraendern? Sonst waere der Knopf ein
|
||||
// Angebot, das ins Leere laeuft.
|
||||
const wouldChange = preview
|
||||
? preview.summary.created + preview.summary.renamed + preview.summary.overwritten > 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
width="xl"
|
||||
title="Projekte importieren"
|
||||
description="Eine zuvor exportierte JSON-Datei einlesen. Vor dem Schreiben gibt es eine Vorschau."
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{/* Datei */}
|
||||
<div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={pickFile}
|
||||
className="hidden"
|
||||
id="import-file"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost w-full justify-start py-3"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
<FileJson className="h-4 w-4 text-violet-300" />
|
||||
{fileName ?? "JSON-Datei auswaehlen…"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Strategie */}
|
||||
{payload ? (
|
||||
<div>
|
||||
<span className="field-label">Bei gleichem Slug</span>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{STRATEGIES.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={busy || Boolean(done)}
|
||||
onClick={() => changeStrategy(option.value)}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5 text-left transition-all disabled:opacity-60",
|
||||
strategy === option.value
|
||||
? "border-violet-400/60 bg-violet-500/12"
|
||||
: "border-white/9 hover:border-white/20",
|
||||
)}
|
||||
>
|
||||
<span className="block text-sm text-slate-100">{option.label}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||
{option.hint}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : 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}
|
||||
|
||||
{/* Vorschau oder Ergebnis */}
|
||||
{summary ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-slate-400">
|
||||
{done ? "Ergebnis:" : "Vorschau:"} {summary.total} Projekt(e)
|
||||
</span>
|
||||
{summary.created > 0 ? <Chip tone="emerald">{summary.created} neu</Chip> : null}
|
||||
{summary.renamed > 0 ? <Chip tone="cyan">{summary.renamed} zusaetzlich</Chip> : null}
|
||||
{summary.overwritten > 0 ? (
|
||||
<Chip tone="amber">{summary.overwritten} ersetzt</Chip>
|
||||
) : null}
|
||||
{summary.skipped > 0 ? <Chip tone="slate">{summary.skipped} uebersprungen</Chip> : null}
|
||||
{summary.errors > 0 ? <Chip tone="rose">{summary.errors} Fehler</Chip> : null}
|
||||
</div>
|
||||
|
||||
{summary.missingConnections > 0 ? (
|
||||
<p className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{summary.missingConnections} Projekt(e) verweisen auf eine Git-Verbindung,
|
||||
die hier nicht existiert. Sie werden ohne Verknuepfung angelegt – lege die
|
||||
Verbindung an und waehle sie im Projekt aus. Access Tokens sind in
|
||||
Exportdateien grundsaetzlich nicht enthalten.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-white/8">
|
||||
{results.map((item) => {
|
||||
const label = ACTION_LABEL[item.action];
|
||||
return (
|
||||
<div
|
||||
key={`${item.slug}-${item.name}`}
|
||||
className="flex items-center justify-between gap-3 border-b border-white/5 px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-slate-200">{item.name}</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 font-mono text-[11px] text-slate-500">
|
||||
<span>{item.slug}</span>
|
||||
{item.newSlug ? (
|
||||
<>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="text-cyan-300">{item.newSlug}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="text-slate-600">· {item.notes} Notizen</span>
|
||||
{item.connection === "missing" ? (
|
||||
<span className="text-amber-300/80">
|
||||
· Verbindung „{item.connectionName}“ fehlt
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{item.message ? (
|
||||
<p className="mt-0.5 text-[11px] text-slate-600">{item.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wider",
|
||||
label.className,
|
||||
)}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{done ? (
|
||||
<p className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Import abgeschlossen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-ghost" onClick={close} disabled={busy}>
|
||||
{done ? "Schliessen" : "Abbrechen"}
|
||||
</button>
|
||||
{!done ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !wouldChange}
|
||||
title={
|
||||
preview && !wouldChange
|
||||
? "Mit dieser Strategie wuerde sich nichts aendern."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => payload && run(payload, strategy, false)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Import ausfuehren
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "emerald" | "cyan" | "amber" | "slate" | "rose";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const tones = {
|
||||
emerald: "bg-emerald-500/12 text-emerald-200",
|
||||
cyan: "bg-cyan-500/12 text-cyan-200",
|
||||
amber: "bg-amber-500/12 text-amber-200",
|
||||
slate: "bg-slate-500/12 text-slate-300",
|
||||
rose: "bg-rose-500/12 text-rose-200",
|
||||
};
|
||||
return <span className={cn("rounded px-1.5 py-0.5", tones[tone])}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Markdown-Darstellung fuer Beschreibungen und Notizen.
|
||||
*
|
||||
* Der Inhalt stammt vom Benutzer, wird aber unveraendert gerendert – ohne
|
||||
* rehype-sanitize waere das ein XSS-Loch, sobald jemand rohes HTML in eine
|
||||
* Notiz schreibt. Das Standardschema erlaubt nur harmlose Elemente; ergaenzt
|
||||
* wird lediglich, was fuer Aufgabenlisten und Codebloecke noetig ist.
|
||||
*/
|
||||
const schema = {
|
||||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
// Checkboxen aus "- [ ] Aufgabe"
|
||||
input: [...(defaultSchema.attributes?.input ?? []), "checked", "disabled", "type"],
|
||||
// Sprachklassen wie "language-ts" fuer Codebloecke
|
||||
code: [...(defaultSchema.attributes?.code ?? []), ["className", /^language-./]],
|
||||
},
|
||||
};
|
||||
|
||||
export function Markdown({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: string | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!children?.trim()) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("md", className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeSanitize, schema]]}
|
||||
components={{
|
||||
// Links aus fremdem Inhalt immer in neuem Tab und ohne
|
||||
// Zugriff auf das oeffnende Fenster.
|
||||
a: ({ href, children: content }) => (
|
||||
<a href={href} target="_blank" rel="noreferrer noopener nofollow">
|
||||
{content}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, NotebookPen, Pencil, Pin, PinOff, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Note } from "@prisma/client";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
||||
|
||||
/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */
|
||||
@@ -118,7 +119,7 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
|
||||
/>
|
||||
<textarea
|
||||
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
|
||||
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
|
||||
placeholder={"Gedanken, Todos, Snippets…\n\nMarkdown wird dargestellt:\n- [ ] offene Aufgabe\n**fett**, `code`, [Link](https://…)"}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
autoFocus
|
||||
@@ -161,9 +162,7 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
|
||||
{note.title}
|
||||
</h3>
|
||||
) : null}
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
|
||||
{note.content}
|
||||
</p>
|
||||
<Markdown className="mt-1">{note.content}</Markdown>
|
||||
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
|
||||
{formatDateTime(note.createdAt)}
|
||||
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
Plus,
|
||||
Rows3,
|
||||
Search,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { ImportDialog } from "@/components/ImportDialog";
|
||||
import { ProjectCard } from "@/components/ProjectCard";
|
||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
||||
@@ -33,6 +45,9 @@ export function ProjectBoard({
|
||||
const [view, setView] = useState<ViewMode>("grid");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const map = new Map<ProjectStatus, number>();
|
||||
@@ -165,6 +180,19 @@ export function ProjectBoard({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
ref={dataButtonRef}
|
||||
type="button"
|
||||
onClick={() => setDataMenuOpen((value) => !value)}
|
||||
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white"
|
||||
aria-label="Daten exportieren oder importieren"
|
||||
title="Export und Import"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={dataMenuOpen}
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Neues Projekt</span>
|
||||
@@ -267,11 +295,54 @@ export function ProjectBoard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Popover
|
||||
anchorRef={dataButtonRef}
|
||||
open={dataMenuOpen}
|
||||
onClose={() => setDataMenuOpen(false)}
|
||||
width={260}
|
||||
>
|
||||
<a
|
||||
href="/api/projects/export"
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setDataMenuOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Download className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
||||
<span>
|
||||
Alle Projekte exportieren
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
JSON mit Notizen, ohne Access Tokens
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setDataMenuOpen(false);
|
||||
setImportOpen(true);
|
||||
}}
|
||||
className="flex w-full items-start gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Upload className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>
|
||||
Projekte importieren
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Mit Vorschau vor dem Schreiben
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Popover>
|
||||
|
||||
<ProjectDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
connections={connections}
|
||||
/>
|
||||
|
||||
<ImportDialog open={importOpen} onClose={() => setImportOpen(false)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ interface FormState {
|
||||
repoUrl: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
autoSync: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -47,6 +48,7 @@ const EMPTY: FormState = {
|
||||
repoUrl: "",
|
||||
repoOwner: "",
|
||||
repoName: "",
|
||||
autoSync: true,
|
||||
};
|
||||
|
||||
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
||||
@@ -87,6 +89,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: project.repoUrl ?? "",
|
||||
repoOwner: project.repoOwner ?? "",
|
||||
repoName: project.repoName ?? "",
|
||||
autoSync: project.autoSync,
|
||||
}
|
||||
: EMPTY,
|
||||
);
|
||||
@@ -154,6 +157,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: form.repoUrl,
|
||||
repoOwner: form.repoOwner,
|
||||
repoName: form.repoName,
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -287,7 +291,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
step={5}
|
||||
value={form.progress}
|
||||
onChange={(event) => update("progress", Number(event.target.value))}
|
||||
className="w-full accent-violet-500"
|
||||
className="range"
|
||||
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -413,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.autoSync}
|
||||
onChange={(event) => update("autoSync", event.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
<span>
|
||||
Am automatischen Abgleich teilnehmen
|
||||
<span className="mt-0.5 block text-[11px] text-slate-600">
|
||||
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
|
||||
synchronisieren geht unabhaengig davon immer.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
||||
|
||||
{repoPickerOpen ? (
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Star,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ProjectDialog } from "@/components/ProjectDialog";
|
||||
import { StatusSelect } from "@/components/StatusSelect";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { PRIORITY_META, accentOf } from "@/lib/status";
|
||||
import { cn, formatDate } from "@/lib/utils";
|
||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||
@@ -30,6 +34,8 @@ export function ProjectHeader({
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [favorite, setFavorite] = useState(project.favorite);
|
||||
const [progress, setProgress] = useState(project.progress);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -162,6 +168,17 @@ export function ProjectHeader({
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<StatusSelect projectId={project.id} status={project.status} />
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
ref={exportButtonRef}
|
||||
type="button"
|
||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
||||
onClick={() => setExportOpen((value) => !value)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={exportOpen}
|
||||
title="Exportieren"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
||||
@@ -193,13 +210,8 @@ export function ProjectHeader({
|
||||
<span>Fortschritt</span>
|
||||
<span className="tabular-nums text-slate-300">{progress}%</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className="accent-bar h-full rounded-full transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(1, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
|
||||
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
@@ -211,10 +223,48 @@ export function ProjectHeader({
|
||||
onTouchEnd={() => patch({ progress })}
|
||||
onKeyUp={() => patch({ progress })}
|
||||
aria-label="Fortschritt anpassen"
|
||||
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
|
||||
className="range"
|
||||
style={{ "--fill": `${progress}%` } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
anchorRef={exportButtonRef}
|
||||
open={exportOpen}
|
||||
onClose={() => setExportOpen(false)}
|
||||
width={250}
|
||||
>
|
||||
<a
|
||||
href={`/api/projects/${project.id}/export`}
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setExportOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<FileJson className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
|
||||
<span>
|
||||
Als JSON
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Zum Wiedereinlesen, mit Notizen
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
href={`/api/projects/${project.id}/export?format=md`}
|
||||
download
|
||||
role="menuitem"
|
||||
onClick={() => setExportOpen(false)}
|
||||
className="flex items-start gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-cyan-300" />
|
||||
<span>
|
||||
Als Markdown
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Lesbar, inklusive Git-Stand
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</Popover>
|
||||
|
||||
<ProjectDialog
|
||||
open={editOpen}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function StatusSelect({
|
||||
onChanged,
|
||||
}: StatusSelectProps) {
|
||||
const router = useRouter();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState<ProjectStatus>(status);
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -54,10 +56,12 @@ export function StatusSelect({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
@@ -82,21 +86,12 @@ export function StatusSelect({
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Auswahl schliessen"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
<Popover
|
||||
anchorRef={triggerRef}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
role="listbox"
|
||||
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
|
||||
width={264}
|
||||
>
|
||||
{STATUS_ORDER.map((value) => {
|
||||
const option = STATUS_META[value];
|
||||
@@ -114,7 +109,7 @@ export function StatusSelect({
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
||||
active ? "bg-white/8" : "hover:bg-white/5",
|
||||
active ? "bg-white/8" : "hover:bg-white/6",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -133,9 +128,7 @@ export function StatusSelect({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+114
-5
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { 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 { Popover } from "@/components/ui/Popover";
|
||||
import type { SessionUser } from "@/lib/auth/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const LINKS = [
|
||||
@@ -10,7 +21,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,14 +41,16 @@ 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>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<nav className="flex items-center gap-1">
|
||||
{LINKS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
@@ -50,7 +71,95 @@ export function TopNav() {
|
||||
);
|
||||
})}
|
||||
</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 triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
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>
|
||||
|
||||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
||||
<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>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,975 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Download,
|
||||
Fingerprint,
|
||||
FolderGit2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
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 AutoSyncReport {
|
||||
at: string;
|
||||
trigger: "zeitplan" | "manuell";
|
||||
candidates: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface AdminData {
|
||||
mode: Mode;
|
||||
allowRegistration: boolean;
|
||||
users: AdminUser[];
|
||||
currentUserId: string;
|
||||
appUrl: string;
|
||||
passkeysAvailable: boolean;
|
||||
autoSync: {
|
||||
enabled: boolean;
|
||||
intervalMinutes: number;
|
||||
activeOnly: boolean;
|
||||
batchSize: number;
|
||||
lastRunAt: string | Date | null;
|
||||
lastReport: AutoSyncReport | null;
|
||||
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
|
||||
eligible: number;
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<AutoSyncSection data={data.autoSync} />
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* ── Sicherung ───────────────────────────────────── */}
|
||||
<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-amber-500/12 text-amber-300">
|
||||
<Database className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Sicherung
|
||||
</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-400">
|
||||
Vollstaendige Sicherung der Instanz als JSON: Einstellungen, Konten,
|
||||
Verbindungen, Projekte und Notizen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/api/admin/backup" download className="btn btn-ghost">
|
||||
<Download className="h-4 w-4" />
|
||||
Sicherung herunterladen
|
||||
</a>
|
||||
|
||||
<p className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-[11px] leading-snug text-amber-200/90">
|
||||
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Die Datei enthaelt Passwort-Hashes und verschluesselte Git-Tokens. Die Tokens
|
||||
sind nur mit demselben <code>APP_SECRET</code> wieder lesbar, Passwoerter
|
||||
liegen als scrypt-Hash vor – trotzdem vertraulich behandeln. Fuer eine
|
||||
byteweise Sicherung der Datenbank gibt es <code>scripts/backup.ps1</code>{" "}
|
||||
beziehungsweise <code>scripts/backup.sh</code> mit <code>pg_dump</code>.
|
||||
</span>
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const INTERVALS = [
|
||||
{ value: 15, label: "alle 15 Minuten" },
|
||||
{ value: 30, label: "alle 30 Minuten" },
|
||||
{ value: 60, label: "stuendlich" },
|
||||
{ value: 180, label: "alle 3 Stunden" },
|
||||
{ value: 360, label: "alle 6 Stunden" },
|
||||
{ value: 720, label: "alle 12 Stunden" },
|
||||
{ value: 1440, label: "taeglich" },
|
||||
];
|
||||
|
||||
function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
|
||||
const router = useRouter();
|
||||
const [enabled, setEnabled] = useState(data.enabled);
|
||||
const [interval, setIntervalMinutes] = useState(data.intervalMinutes);
|
||||
const [activeOnly, setActiveOnly] = useState(data.activeOnly);
|
||||
const [batchSize, setBatchSize] = useState(data.batchSize);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [notice, setNotice] = useState<Notice>(null);
|
||||
|
||||
async function patch(payload: Record<string, unknown>) {
|
||||
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.");
|
||||
setNotice({ ok: true, text: "Gespeichert." });
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setEnabled(data.enabled);
|
||||
setIntervalMinutes(data.intervalMinutes);
|
||||
setActiveOnly(data.activeOnly);
|
||||
setBatchSize(data.batchSize);
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow() {
|
||||
setRunning(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await fetch("/api/admin/sync/run", { method: "POST" });
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen.");
|
||||
const report = body.report as AutoSyncReport;
|
||||
setNotice({
|
||||
ok: true,
|
||||
text:
|
||||
`${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` +
|
||||
`${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`,
|
||||
});
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const nextRun =
|
||||
enabled && data.lastRunAt
|
||||
? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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-cyan-500/12 text-cyan-300">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
||||
Automatischer Abgleich
|
||||
</h2>
|
||||
<p className="mt-1 max-w-xl text-sm text-slate-400">
|
||||
Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle
|
||||
Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was
|
||||
ohnehin verknuepft ist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
||||
onClick={runNow}
|
||||
disabled={running || data.eligible === 0}
|
||||
title={data.eligible === 0 ? "Kein Projekt mit Repository verknuepft" : undefined}
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Jetzt abgleichen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setEnabled(event.target.checked);
|
||||
void patch({ autoSyncEnabled: event.target.checked });
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-slate-200">Regelmaessig abgleichen</span>
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Standardmaessig aus. Jeder Durchlauf verbraucht Kontingent bei GitHub,
|
||||
GitLab und Gitea – ohne Token ist das schnell erschoepft.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="field-label" htmlFor="autosync-interval">
|
||||
Abstand
|
||||
</label>
|
||||
<select
|
||||
id="autosync-interval"
|
||||
className="field"
|
||||
value={interval}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
setIntervalMinutes(value);
|
||||
void patch({ autoSyncIntervalMinutes: value });
|
||||
}}
|
||||
>
|
||||
{INTERVALS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="autosync-batch">
|
||||
Projekte je Durchlauf: {batchSize}
|
||||
</label>
|
||||
<input
|
||||
id="autosync-batch"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={batchSize}
|
||||
disabled={busy}
|
||||
onChange={(event) => setBatchSize(Number(event.target.value))}
|
||||
onMouseUp={() => patch({ autoSyncBatchSize: batchSize })}
|
||||
onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })}
|
||||
onKeyUp={() => patch({ autoSyncBatchSize: batchSize })}
|
||||
className="range mt-2"
|
||||
style={{ "--fill": `${batchSize}%` } as React.CSSProperties}
|
||||
/>
|
||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
||||
Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeOnly}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setActiveOnly(event.target.checked);
|
||||
void patch({ autoSyncActiveOnly: event.target.checked });
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm text-slate-200">
|
||||
Fertige und archivierte Projekte auslassen
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
||||
Dort aendert sich selten noch etwas.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Zustand */}
|
||||
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||
Verknuepfte Projekte
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.eligible}</dd>
|
||||
</div>
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||
Letzter Durchlauf
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
||||
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="glass-soft px-3 py-2.5">
|
||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
||||
Naechster Durchlauf
|
||||
</dt>
|
||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
||||
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{data.lastReport ? (
|
||||
<p className="mt-3 text-[11px] text-slate-500" suppressHydrationWarning>
|
||||
Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "}
|
||||
{data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt
|
||||
in {Math.round(data.lastReport.durationMs / 1000)} s.
|
||||
</p>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,6 +15,14 @@ interface ModalProps {
|
||||
width?: "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog per Portal am body.
|
||||
*
|
||||
* Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten
|
||||
* heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen
|
||||
* Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am
|
||||
* Fenster.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
@@ -23,6 +32,10 @@ export function Modal({
|
||||
footer,
|
||||
width = "lg",
|
||||
}: ModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -37,22 +50,22 @@ export function Modal({
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dialog schliessen"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm"
|
||||
className="fixed inset-0 cursor-default bg-[#03040c]/85 backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
"glass fade-up relative z-10 my-auto w-full p-6",
|
||||
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
|
||||
width === "md" && "max-w-lg",
|
||||
width === "lg" && "max-w-2xl",
|
||||
width === "xl" && "max-w-4xl",
|
||||
@@ -79,6 +92,7 @@ export function Modal({
|
||||
|
||||
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Schwebendes Menue, das per Portal am body haengt.
|
||||
*
|
||||
* Warum nicht einfach ein absolut positioniertes Kind?
|
||||
*
|
||||
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
|
||||
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
|
||||
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
|
||||
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
|
||||
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
|
||||
* selbst position: fixed bezieht sich auf die Karte statt auf das
|
||||
* Fenster.
|
||||
*
|
||||
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
|
||||
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
|
||||
*/
|
||||
export function Popover({
|
||||
anchorRef,
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
align = "end",
|
||||
width = 240,
|
||||
className,
|
||||
labelledBy,
|
||||
role = "menu",
|
||||
}: {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
/** "end" richtet die rechte Kante am Anker aus. */
|
||||
align?: "start" | "end";
|
||||
width?: number;
|
||||
className?: string;
|
||||
labelledBy?: string;
|
||||
role?: "menu" | "listbox" | "dialog";
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) return;
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const gap = 6;
|
||||
|
||||
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
||||
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
||||
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
||||
// Ausloeser.
|
||||
let left = align === "end" ? rect.right - width : rect.left;
|
||||
if (left < margin) left = rect.left;
|
||||
if (left + width > window.innerWidth - margin) left = rect.right - width;
|
||||
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
|
||||
|
||||
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
||||
let top = rect.bottom + gap;
|
||||
|
||||
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
|
||||
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
|
||||
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
||||
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
||||
|
||||
setPosition({ top, left });
|
||||
}, [anchorRef, align, width]);
|
||||
|
||||
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
||||
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
|
||||
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
place();
|
||||
}, [open, place]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// capture: true, damit auch das Scrollen innerer Container ankommt.
|
||||
const onScroll = () => place();
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (panelRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") onClose();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, place, onClose, anchorRef]);
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role={role}
|
||||
aria-labelledby={labelledBy}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: position?.top ?? -9999,
|
||||
left: position?.left ?? -9999,
|
||||
width,
|
||||
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
||||
// Menue kurz an der falschen Stelle auf.
|
||||
visibility: position ? "visible" : "hidden",
|
||||
}}
|
||||
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wird von Next einmal beim Start des Servers ausgefuehrt.
|
||||
*
|
||||
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
|
||||
*
|
||||
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
|
||||
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
|
||||
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
|
||||
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
|
||||
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
|
||||
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
|
||||
* Build schluege fehl.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { describeConfig } = await import("@/lib/config");
|
||||
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
|
||||
|
||||
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
|
||||
startAutoSyncScheduler();
|
||||
}
|
||||
}
|
||||
@@ -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(" ");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Austauschformat fuer Projekte.
|
||||
*
|
||||
* Bewusst schlank und ohne Geheimnisse:
|
||||
* - keine Access Tokens. Die Verbindung wird nur beschrieben (Name, System,
|
||||
* Basis-URL), das Token muss beim Import neu hinterlegt werden. Eine
|
||||
* Exportdatei landet sonst schnell in einem Download-Ordner oder einem
|
||||
* Cloud-Abgleich.
|
||||
* - keine Repo-Snapshots. Die sind reproduzierbar und blaehen die Datei auf;
|
||||
* ein Abgleich nach dem Import holt sie zurueck.
|
||||
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
|
||||
* wurde.
|
||||
*/
|
||||
|
||||
export const EXPORT_KIND = "nebula.projects";
|
||||
export const EXPORT_VERSION = 1;
|
||||
|
||||
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||
|
||||
export const exportedNoteSchema = z.object({
|
||||
title: z.string().max(160).nullable().optional(),
|
||||
content: z.string().max(20000),
|
||||
pinned: z.boolean().optional().default(false),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportedConnectionSchema = z.object({
|
||||
name: z.string().max(80),
|
||||
provider: z.enum(PROVIDERS),
|
||||
baseUrl: z.string().max(300),
|
||||
});
|
||||
|
||||
export const exportedProjectSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
slug: z.string().max(60).optional(),
|
||||
summary: z.string().max(280).nullable().optional(),
|
||||
description: z.string().max(20000).nullable().optional(),
|
||||
status: z.enum(STATUS).optional().default("IDEA"),
|
||||
priority: z.number().int().min(1).max(4).optional().default(2),
|
||||
progress: z.number().int().min(0).max(100).optional().default(0),
|
||||
accent: z.string().max(20).optional().default("violet"),
|
||||
tags: z.array(z.string().max(30)).max(12).optional().default([]),
|
||||
favorite: z.boolean().optional().default(false),
|
||||
autoSync: z.boolean().optional().default(true),
|
||||
repoUrl: z.string().max(500).nullable().optional(),
|
||||
repoOwner: z.string().max(200).nullable().optional(),
|
||||
repoName: z.string().max(200).nullable().optional(),
|
||||
/** Nur zur Wiedererkennung – ohne Token. */
|
||||
connection: exportedConnectionSchema.nullable().optional(),
|
||||
notes: z.array(exportedNoteSchema).max(500).optional().default([]),
|
||||
createdAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const exportFileSchema = z.object({
|
||||
kind: z.literal(EXPORT_KIND),
|
||||
version: z.number().int().min(1).max(EXPORT_VERSION),
|
||||
exportedAt: z.string().optional(),
|
||||
app: z.string().optional(),
|
||||
projects: z.array(exportedProjectSchema).min(1).max(1000),
|
||||
});
|
||||
|
||||
export type ExportedProject = z.infer<typeof exportedProjectSchema>;
|
||||
export type ExportFile = z.infer<typeof exportFileSchema>;
|
||||
|
||||
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */
|
||||
export function exportFileName(prefix: string, date: Date, extension: string): string {
|
||||
const stamp = date.toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
||||
const safe = prefix
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return `${safe || "nebula"}-${stamp}.${extension}`;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { GitConnection, Note, Project, RepoSnapshot } from "@prisma/client";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PRIORITY_META, statusOf } from "@/lib/status";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
|
||||
|
||||
type ProjectForExport = Project & {
|
||||
notes: Note[];
|
||||
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
|
||||
};
|
||||
|
||||
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
|
||||
export function toExportedProject(project: ProjectForExport): ExportedProject {
|
||||
return {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
summary: project.summary,
|
||||
description: project.description,
|
||||
status: project.status,
|
||||
priority: project.priority,
|
||||
progress: project.progress,
|
||||
accent: project.accent,
|
||||
tags: project.tags,
|
||||
favorite: project.favorite,
|
||||
autoSync: project.autoSync,
|
||||
repoUrl: project.repoUrl,
|
||||
repoOwner: project.repoOwner,
|
||||
repoName: project.repoName,
|
||||
connection: project.connection
|
||||
? {
|
||||
name: project.connection.name,
|
||||
provider: project.connection.provider,
|
||||
baseUrl: project.connection.baseUrl,
|
||||
}
|
||||
: null,
|
||||
notes: project.notes.map((note) => ({
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
pinned: note.pinned,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
})),
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildExportFile(projects: ProjectForExport[]): ExportFile {
|
||||
return {
|
||||
kind: EXPORT_KIND,
|
||||
version: EXPORT_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
app: APP_NAME,
|
||||
projects: projects.map(toExportedProject),
|
||||
};
|
||||
}
|
||||
|
||||
/** Laedt die Projekte eines Kontos im fuer den Export noetigen Umfang. */
|
||||
export async function loadProjectsForExport(
|
||||
ownerId: string,
|
||||
projectId?: string,
|
||||
): Promise<ProjectForExport[]> {
|
||||
return prisma.project.findMany({
|
||||
where: { ownerId, ...(projectId ? { id: projectId } : {}) },
|
||||
include: {
|
||||
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
|
||||
connection: { select: { name: true, provider: true, baseUrl: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Markdown ─────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
GITHUB: "GitHub",
|
||||
GITLAB: "GitLab",
|
||||
GITEA: "Gitea",
|
||||
};
|
||||
|
||||
/**
|
||||
* Ein Projekt als lesbares Markdown – zum Archivieren oder als Grundlage
|
||||
* fuer ein README. Der Git-Stand kommt aus dem letzten Snapshot, damit die
|
||||
* Datei auch ohne Netz vollstaendig ist.
|
||||
*/
|
||||
export function toMarkdown(
|
||||
project: ProjectForExport & { snapshot: RepoSnapshot | null },
|
||||
): string {
|
||||
const status = statusOf(project.status);
|
||||
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# ${project.name}`, "");
|
||||
if (project.summary) lines.push(`> ${project.summary}`, "");
|
||||
|
||||
lines.push("| | |", "| --- | --- |");
|
||||
lines.push(`| Status | ${status.label} |`);
|
||||
lines.push(`| Fortschritt | ${project.progress} % |`);
|
||||
lines.push(`| Prioritaet | ${priority.label} |`);
|
||||
if (project.tags.length) {
|
||||
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
|
||||
}
|
||||
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
|
||||
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
|
||||
if (project.connection) {
|
||||
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
|
||||
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (project.description) {
|
||||
lines.push("## Beschreibung", "", project.description, "");
|
||||
}
|
||||
|
||||
const snapshot = project.snapshot;
|
||||
if (snapshot) {
|
||||
lines.push("## Stand des Repositories", "");
|
||||
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, "");
|
||||
lines.push("| | |", "| --- | --- |");
|
||||
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
|
||||
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
|
||||
lines.push(`| Branches | ${snapshot.branchCount} |`);
|
||||
lines.push(`| Tags | ${snapshot.tagCount} |`);
|
||||
lines.push(`| Offene PRs/MRs | ${snapshot.openPullRequests} |`);
|
||||
lines.push(`| Offene Issues | ${snapshot.openIssues} |`);
|
||||
if (snapshot.lastCommitSha) {
|
||||
lines.push(
|
||||
`| Letzter Commit | \`${snapshot.lastCommitSha.slice(0, 7)}\` – ` +
|
||||
`${snapshot.lastCommitMessage ?? ""} |`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (project.notes.length) {
|
||||
lines.push("## Notizen", "");
|
||||
for (const note of project.notes) {
|
||||
const pin = note.pinned ? " 📌" : "";
|
||||
lines.push(`### ${note.title ?? "Ohne Titel"}${pin}`, "");
|
||||
lines.push(note.content, "");
|
||||
lines.push(`<sub>${formatDateTime(note.createdAt)}</sub>`, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("---", "");
|
||||
lines.push(`<sub>Exportiert aus ${APP_NAME} am ${formatDateTime(new Date())}.</sub>`, "");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -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,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/** Groesste akzeptierte JSON-Nutzlast im Normalfall. */
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Groessere Obergrenze fuer den Import: eine Exportdatei mit vielen Notizen
|
||||
* sprengt 256 KB schnell.
|
||||
*/
|
||||
export const IMPORT_MAX_BODY_BYTES = 8 * 1024 * 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,
|
||||
maxBytes: number = MAX_BODY_BYTES,
|
||||
): 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 > maxBytes) {
|
||||
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 > maxBytes) {
|
||||
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;
|
||||
}
|
||||
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { syncError: message, lastSyncedAt: new Date() },
|
||||
data: {
|
||||
syncError: message,
|
||||
lastSyncedAt: new Date(),
|
||||
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
|
||||
syncFailureCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
return { ok: false, message };
|
||||
}
|
||||
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
syncError: null,
|
||||
syncFailureCount: 0,
|
||||
defaultBranch: data.defaultBranch,
|
||||
repoUrl: data.htmlUrl ?? undefined,
|
||||
},
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { getAppSettings } from "@/lib/auth/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncProject } from "@/lib/sync";
|
||||
|
||||
/**
|
||||
* Automatischer Abgleich mit den Git-Systemen.
|
||||
*
|
||||
* Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer
|
||||
* eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein
|
||||
* zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration.
|
||||
* Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run
|
||||
* auf und laesst den Automatismus aus.
|
||||
*
|
||||
* Ruecksicht auf fremde Kontingente:
|
||||
* - je Durchlauf nur eine begrenzte Zahl Projekte
|
||||
* - die am laengsten nicht abgeglichenen zuerst
|
||||
* - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand
|
||||
* - kurze Pause zwischen den Projekten
|
||||
*/
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
/** Kleine Verschnaufpause zwischen zwei Projekten. */
|
||||
const PAUSE_MS = 400;
|
||||
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
|
||||
const MAX_BACKOFF_STEPS = 4;
|
||||
|
||||
export interface AutoSyncReport {
|
||||
at: string;
|
||||
trigger: "zeitplan" | "manuell";
|
||||
candidates: number;
|
||||
synced: number;
|
||||
failed: number;
|
||||
/** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */
|
||||
skipped: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
// Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei
|
||||
// jedem Neuladen einen weiteren Zeitgeber startet.
|
||||
const globalForScheduler = globalThis as unknown as {
|
||||
nebulaAutoSyncTimer?: NodeJS.Timeout;
|
||||
nebulaAutoSyncRunning?: boolean;
|
||||
};
|
||||
|
||||
export function startAutoSyncScheduler() {
|
||||
if (globalForScheduler.nebulaAutoSyncTimer) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void tick();
|
||||
}, TICK_MS);
|
||||
// Der Zeitgeber darf den Prozess nicht am Beenden hindern.
|
||||
timer.unref?.();
|
||||
globalForScheduler.nebulaAutoSyncTimer = timer;
|
||||
|
||||
// Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten.
|
||||
const initial = setTimeout(() => void tick(), 20_000);
|
||||
initial.unref?.();
|
||||
|
||||
console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft.");
|
||||
}
|
||||
|
||||
/** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */
|
||||
async function tick() {
|
||||
try {
|
||||
const settings = await getAppSettings();
|
||||
if (!settings.autoSyncEnabled) return;
|
||||
|
||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||||
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
|
||||
if (Date.now() - last < intervalMs) return;
|
||||
|
||||
await runAutoSyncPass("zeitplan");
|
||||
} catch (error) {
|
||||
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
|
||||
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
|
||||
*/
|
||||
export async function runAutoSyncPass(
|
||||
trigger: AutoSyncReport["trigger"],
|
||||
): Promise<AutoSyncReport | null> {
|
||||
if (globalForScheduler.nebulaAutoSyncRunning) return null;
|
||||
globalForScheduler.nebulaAutoSyncRunning = true;
|
||||
|
||||
const startedAt = Date.now();
|
||||
let synced = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
try {
|
||||
const settings = await getAppSettings();
|
||||
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
|
||||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||||
|
||||
const where: Prisma.ProjectWhereInput = {
|
||||
autoSync: true,
|
||||
// Ohne Repository gibt es nichts abzugleichen.
|
||||
OR: [
|
||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
||||
{ repoUrl: { not: null } },
|
||||
],
|
||||
...(settings.autoSyncActiveOnly
|
||||
? { status: { notIn: ["DONE", "ARCHIVED"] } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
|
||||
const candidates = await prisma.project.findMany({
|
||||
where,
|
||||
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
|
||||
take: batchSize * 4,
|
||||
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
|
||||
});
|
||||
|
||||
const due = candidates.filter((project) => {
|
||||
if (project.syncFailureCount === 0) return true;
|
||||
if (!project.lastSyncedAt) return true;
|
||||
|
||||
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
|
||||
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS);
|
||||
const wait = intervalMs * 2 ** steps;
|
||||
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
|
||||
if (!ready) skipped += 1;
|
||||
return ready;
|
||||
});
|
||||
|
||||
const batch = due.slice(0, batchSize);
|
||||
|
||||
for (const project of batch) {
|
||||
const result = await syncProject(project.id);
|
||||
if (result.ok) synced += 1;
|
||||
else failed += 1;
|
||||
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
|
||||
}
|
||||
|
||||
const report: AutoSyncReport = {
|
||||
at: new Date().toISOString(),
|
||||
trigger,
|
||||
candidates: candidates.length,
|
||||
synced,
|
||||
failed,
|
||||
skipped,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
|
||||
await prisma.appSettings.update({
|
||||
where: { id: "singleton" },
|
||||
data: {
|
||||
lastAutoSyncAt: new Date(),
|
||||
lastAutoSyncReport: report as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` +
|
||||
`${skipped} zurueckgestellt, ${report.durationMs} ms`,
|
||||
);
|
||||
|
||||
return report;
|
||||
} finally {
|
||||
globalForScheduler.nebulaAutoSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAutoSyncRunning(): boolean {
|
||||
return Boolean(globalForScheduler.nebulaAutoSyncRunning);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export const projectCreateSchema = z.object({
|
||||
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
||||
/** Am automatischen Abgleich teilnehmen. */
|
||||
autoSync: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||
@@ -48,6 +50,93 @@ 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(),
|
||||
autoSyncEnabled: z.boolean().optional(),
|
||||
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
|
||||
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
|
||||
autoSyncActiveOnly: z.boolean().optional(),
|
||||
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).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