15 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 dd0956ec71 chore: ungenutzte experimental.serverActions-Option entfernen
Die Anwendung nutzt ausschliesslich Route Handler, keine Server Actions. Die
Option bewirkte nichts, liess Next beim Build aber "Experiments (use with
caution)" ausgeben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
Florian WildeandClaude Opus 5 3306d49ceb fix(docker): Prisma-CLI im Laufzeit-Image lauffaehig machen
Der App-Container brach beim Start mit "Cannot find module 'effect'" ab und
lief in die Wiederholschleife des Entrypoints. Ursache: aus dem Builder
wurden nur node_modules/prisma und node_modules/@prisma uebernommen. Die CLI
laedt aber @prisma/config, und das zieht einen eigenen Abhaengigkeitsbaum
(effect, c12, …) nach, der ausserhalb dieser beiden Verzeichnisse liegt.

Statt einzelne Pakete nachzukopieren bekommt die CLI jetzt eine eigene,
minimale Installation unter /opt/prisma-cli. Die Version wird aus der
package.json des Builders gelesen, damit CLI und generierter Client nicht
auseinanderlaufen. Der Entrypoint bricht ausserdem mit klarer Meldung ab,
wenn die CLI fehlt, statt fuenfzehnmal auf eine vermeintlich nicht bereite
Datenbank zu warten.

Verifiziert: docker compose --profile full up -d --build spielt die
Migrationen ein, der Container meldet sich als healthy und erreicht die
Git-APIs nach aussen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
Florian WildeandClaude Opus 5 b818a4c143 chore: package-lock.json ergaenzen und next-env.d.ts aktualisieren
Der Lockfile fixiert die tatsaechlich aufgeloesten Versionen (Next 15.5.21,
Prisma 6.19.3, React 19) und ist Voraussetzung fuer "npm ci" im
Docker-Build. next-env.d.ts wurde beim ersten Build von Next um die
Routen-Typen ergaenzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:25 +02:00
Florian WildeandClaude Opus 5 49c7b0a137 refactor: ungenutzte StatusBadge-Komponente entfernen
Board und Detailseite benutzen durchgaengig StatusSelect, das die Pille
bereits rendert und zusaetzlich den Wechsel erlaubt. Die reine Anzeige-
variante hatte damit keinen Aufrufer mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:45 +02:00
Florian WildeandClaude Opus 5 9e9ad5e441 docs: README und Funktionsuebersicht
README als Einstieg: was Nebula ist, drei Wege zur Inbetriebnahme
(Standalone, komplett Docker, eigene PostgreSQL), Tabelle aller
Umgebungsvariablen, Anleitung fuer die Git-Verbindung inklusive der noetigen
Token-Scopes je System, Projektstruktur, npm-Skripte, API-Uebersicht,
Sicherheitshinweise und eine Fehlersuchtabelle.

FEATURES.md listet den Funktionsumfang vollstaendig nach Bereichen auf:
Projekte und Status, Dashboard, Notizen, Git-Anbindung inklusive der beim
Sync geholten Daten, Oberflaeche sowie Datenmodell, Sicherheit und Betrieb.
Am Ende stehen bewusst nicht umgesetzte Ideen fuer spaeter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:14 +02:00
Florian WildeandClaude Opus 5 d5c05bccdd build: Docker-Betrieb und Standalone-Betrieb gleichwertig unterstuetzen
Dieselbe Codebasis laeuft nativ auf dem Rechner oder komplett im Container.

docker-compose.yml mit Profilen statt mehrerer Dateien:
- "db" laeuft immer  -> docker compose up -d db, App dann lokal per npm
- --profile full     -> zusaetzlich der App-Container
- --profile tools    -> Adminer als Datenbankoberflaeche

Dockerfile als Multi-Stage-Build auf dem Next-Standalone-Output. In die
Laufzeitschicht wandern zusaetzlich Prisma-CLI, Engines und Schema, damit der
Entrypoint beim Start "migrate deploy" ausfuehren kann. Das Alpine-Image
bekommt openssl fuer die Prisma-Engines, der Client wird zusaetzlich fuer
linux-musl-openssl-3.0.x erzeugt. Der Container laeuft als unprivilegierter
Benutzer und meldet seinen Zustand ueber /api/health.

Der Entrypoint wartet in einer Schleife auf die Datenbank, ergaenzend zum
pg_isready-Healthcheck und der depends_on-Bedingung.

scripts/setup.mjs legt die .env aus der Vorlage an und erzeugt dabei ein
zufaelliges APP_SECRET, damit die Tokens nicht mit dem Standardschluessel
verschluesselt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:35:04 +02:00
Florian WildeandClaude Opus 5 3cba81b243 feat(ui): Verwaltung der Git-Verbindungen
Eine Seite fuer alle angebundenen Git-Systeme. Mehrere Verbindungen desselben
Typs sind moeglich, etwa privates GitHub und Firmen-Gitea nebeneinander.

- Anlegen mit Systemauswahl, Basis-URL und Access Token; die Basis-URL wird
  beim Wechsel des Systems auf den passenden Standard gesetzt, sofern sie
  nicht von Hand geaendert wurde
- Verbindungstest auf Knopfdruck: prueft Erreichbarkeit und Token, meldet den
  erkannten Benutzer und bei Gitea die Serverversion. Ergebnis und Zeitpunkt
  werden gespeichert und in der Karte angezeigt
- Token erscheint nur als Hinweis; ein leeres Feld beim Bearbeiten laesst ihn
  unveraendert
- Loeschen trennt nur die Verknuepfung, die Projekte bleiben erhalten
- Hinweis auf die noetigen Scopes samt Link zur Doku des jeweiligen Systems

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:51 +02:00
Florian WildeandClaude Opus 5 674b570d57 feat(ui): Projektdetail mit Notizen und Git-Panel
Kopfbereich mit Statusauswahl samt Erklaerung je Zustand, Favorit,
Fortschrittsregler, Bearbeiten und Loeschen.

Notizen:
- beliebig viele je Projekt, Titel optional, Zeilenumbrueche bleiben erhalten
- anpinnen, bearbeiten, loeschen direkt in der Liste
- "bearbeitet vor ..." erscheint nur bei echter Aenderung, nicht direkt nach
  dem Anlegen

Git-Panel:
- Kennzahlen zu Stars, Forks, offenen PRs/MRs, Issues, Branches und Groesse
- letzter Commit und aktuelle Version als eigene Kacheln
- Sprachverteilung als Balken
- Reiter fuer Branches, Tags, Commits und Releases mit Links ins Repo
- "Jetzt synchronisieren" holt den Stand; Fehler erscheinen im Klartext statt
  als Fehlerseite

Ohne verknuepftes Repository zeigt das Panel einen Hinweis statt leerer
Kacheln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:40 +02:00
Florian WildeandClaude Opus 5 7bd87d0e61 feat(ui): Dashboard mit Board, Filtern und direktem Statuswechsel
- Fuenf Kennzahlen ueber dem Board: Projektzahl mit Durchschnittsfortschritt,
  Ideen, aktive Entwicklungen, fertige Projekte, verknuepfte Repos
- Volltextsuche ueber Name, Beschreibung, Tags und Repo-Pfad
- Statusfilter als Chips mit Trefferzaehler, mehrfach kombinierbar
- Sortierung nach Aenderung, Anlage, Name, Fortschritt oder Prioritaet;
  Favoriten stehen immer oben
- Zwei Ansichten: freies Raster oder nach Status gruppiert
- Archiv standardmaessig ausgeblendet, per Schalter sichtbar

Die Projektkarte zeigt Fortschritt, Provider, Default-Branch, letzten Tag,
Alter des letzten Commits, Notizzahl und Sync-Fehler. Status und Favorit sind
direkt auf der Karte aenderbar - beides optimistisch, mit Rollback wenn der
Server ablehnt. Die Karte ist ein "stretched link", damit die Schaltflaechen
darin nicht in einem Anker verschachtelt sind.

Der Anlege-Dialog verlangt nur einen Namen; neue Projekte starten als Idee.
Ist eine Git-Verbindung gewaehlt, laesst sich das Repository aus einer
durchsuchbaren Liste uebernehmen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:29 +02:00
Florian WildeandClaude Opus 5 8362485318 feat(ui): Dark-Glassmorphism-Designsystem und Grundlayout
Optischer Rahmen der Anwendung:

- Hintergrund aus drei langsam driftenden Nebeln, feinem Raster und einer
  Rauschtextur gegen Farbbanding
- .glass / .glass-soft als Grundflaechen, .card-glow fuer den Hover-Effekt
  mit Farbverlaufsrand je Projektakzent
- Buttons, Formularfelder und Scrollbars im gleichen Stil
- prefers-reduced-motion schaltet saemtliche Animationen ab

Dazu Kopfzeile mit aktiver Navigationsmarkierung, ein wiederverwendbarer
Modal-Dialog (Escape schliesst, Scroll wird gesperrt), das SVG-Icon sowie
Fehler- und 404-Seiten. Die Fehlerseite erkennt Datenbankprobleme und nennt
direkt die drei Befehle, die in dem Fall meistens helfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:15 +02:00
Florian WildeandClaude Opus 5 8f92ec27be feat(api): REST-Endpunkte fuer Projekte, Notizen, Verbindungen und Sync
Alles, was die Oberflaeche kann, ist damit auch scriptbar:

  GET  POST           /api/projects
  GET  PATCH DELETE   /api/projects/:id
  POST                /api/projects/:id/sync
  GET  POST           /api/projects/:id/notes
  PATCH DELETE        /api/notes/:id
  GET  POST           /api/connections
  PATCH DELETE        /api/connections/:id
  POST                /api/connections/:id/check
  GET                 /api/connections/:id/repos
  GET                 /api/health

Details:
- Jede Eingabe laeuft durch Zod; Feldfehler kommen als Objekt zurueck, damit
  Formulare sie direkt anzeigen koennen.
- Beim Anlegen werden Owner und Repo-Name aus der URL abgeleitet, falls nicht
  explizit angegeben.
- Ein leeres Token-Feld beim Bearbeiten einer Verbindung laesst das
  gespeicherte Token unveraendert; null entfernt es.
- Verbindungsantworten enthalten nie das Chiffrat, nur einen Hinweis
  wie "ghp_...9f3a".
- /api/health prueft die Datenbank und dient dem Docker-Healthcheck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:34:02 +02:00
Florian WildeandClaude Opus 5 2b657a2638 feat(git): Adapter fuer GitHub, GitLab und Gitea
Ein gemeinsames Interface (GitClient) mit drei Implementierungen. Jeder
Adapter liefert dieselbe normalisierte Struktur, sodass die Oberflaeche die
Unterschiede der APIs nicht kennen muss:

- GitHub  – REST v3, Bearer-Token, funktioniert auch mit Enterprise (/api/v3).
            open_issues_count enthaelt dort PRs, das wird herausgerechnet.
- GitLab  – API v4, PRIVATE-TOKEN, Projekte ueber den URL-kodierten Pfad,
            damit verschachtelte Gruppen funktionieren.
- Gitea   – API v1, token-Header, deckt auch Forgejo ab.

Geholt werden Repo-Eckdaten, Branches, Tags, Commits, Releases, Sprachen und
offene PRs/MRs. Die Teilabfragen laufen parallel; eine fehlschlagende Liste
bricht den restlichen Sync nicht ab.

Weiteres:
- http.ts uebersetzt HTTP-Status in verstaendliche Meldungen (401 Token,
  403 Scopes/Rate-Limit, 404 Pfad) und erzwingt ein Timeout.
- parseRepoUrl versteht https-, ssh- und GitLab-Weboberflaechen-URLs.
- meta.ts haelt die Provider-Metadaten frei von node:crypto, damit
  Client-Komponenten sie importieren koennen.
- sync.ts schreibt das Ergebnis als Snapshot und vermerkt Fehler am Projekt,
  statt eine Ausnahme bis in die Seite durchzureichen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:50 +02:00
Florian WildeandClaude Opus 5 deeeaf602e feat(core): Verschluesselung, Validierung und gemeinsame Helfer
- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt
  aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>),
  damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt
  die App und faellt auf einen Entwicklungsschluessel zurueck.
- validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive
  Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche.
- status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer
  Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen.
- utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone
  (verhindert Abweichungen zwischen Server-Render und Hydration) und
  relative Zeitangaben.
- projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst
  tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt.
- db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim
  Hot Reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:35 +02:00
Florian WildeandClaude Opus 5 a6ce3979ab feat(db): Prisma-Datenmodell fuer Projekte, Notizen und Git-Verbindungen
Vier Tabellen in PostgreSQL:

- Project: Stammdaten, Status, Prioritaet, Fortschritt, Tags, Akzentfarbe
  und die optionale Repo-Verknuepfung
- Note: beliebig viele Notizen je Projekt, anpinnbar, Cascade-Delete
- GitConnection: ein Eintrag je Git-System inkl. verschluesseltem Token
- RepoSnapshot: zwischengespeicherter Repo-Stand, damit das Dashboard
  auch ohne Netz oder bei erreichtem Rate-Limit Daten zeigt

ProjectStatus deckt den Lebenszyklus von der Idee bis zum Archiv ab.
Branch-, Tag- und Commit-Listen liegen als JSONB, damit providerspezifische
Felder nicht verloren gehen.

Enthaelt die Initial-Migration (haendisch gepflegt, damit "prisma migrate
deploy" im Container ohne Shadow-Datenbank durchlaeuft) und einen
idempotenten Seed mit drei Beispielprojekten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:23 +02:00
Florian WildeandClaude Opus 5 67442218a0 chore: Projektgeruest mit Next.js 15, TypeScript und Tailwind CSS 4
Grundlage fuer Nebula, das Dashboard fuer Vibe-Coding Projekte.

- Next.js 15 (App Router) mit React 19 und TypeScript im strict-Modus
- Tailwind CSS 4 ueber @tailwindcss/postcss, ohne separate Config-Datei
- output: "standalone", damit dasselbe Build sowohl nativ als auch im
  Docker-Image laeuft
- Pfad-Alias @/* auf src/
- .gitattributes erzwingt LF fuer Shell-Skripte (wichtig fuers Image)
- MIT-Lizenz

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 19:33:12 +02:00
120 changed files with 569 additions and 13945 deletions
+9 -47
View File
@@ -4,28 +4,6 @@
# (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.
@@ -40,39 +18,23 @@ 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 Git-Tokens
# nicht mehr entschluesselbar und muessen neu hinterlegt werden. Anmeldungen
# und Passkeys sind davon nicht betroffen.
# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Tokens
# nicht mehr entschluesselbar und muessen neu hinterlegt werden.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen.
SESSION_TTL_DAYS="30"
# ── 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"
# ── 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"
-3
View File
@@ -43,9 +43,6 @@ yarn-error.log*
/data
/postgres-data
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
/backups
# editors
.idea/
.vscode/*
+15 -203
View File
@@ -5,52 +5,6 @@ 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
@@ -107,28 +61,10 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben.
- **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks".
- **Zwei Ansichten**: freies Raster oder nach Status gruppierte Abschnitte.
- **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
- Gezogen wird am Griff links, nicht an der ganzen Karte — so bleibt der Titel ein
normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten.
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut.
### Projektkarte auf einen Blick
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
@@ -140,10 +76,6 @@ Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
- **Markdown wird dargestellt** — Überschriften, Listen, Tabellen, Codeblöcke, Zitate und
Links. Aus `- [ ] Aufgabe` werden echte Checklisten. Gilt auch für die Projekt-
beschreibung. Rohes HTML wird beim Rendern entfernt (`rehype-sanitize`) — der Inhalt
stammt vom Benutzer und wäre sonst ein XSS-Loch.
- **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
@@ -186,62 +118,14 @@ Firmen-Gitea nebeneinander.
| --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
| **Pull-/Merge-Requests** | bis zu 50 offene, mit Nummer, Titel, Autor, Alter, Labels, Entwurfskennzeichnung und Link |
| **Issues** | bis zu 50 offene, gleiche Angaben. Bei GitHub und Gitea werden PRs herausgefiltert, die dort in derselben Liste stecken |
| **CI-Status** | Letzter Lauf auf dem Default-Branch: erfolgreich, fehlgeschlagen, läuft, wartet oder abgebrochen — mit Name und Link |
| Branches | bis zu 50, mit SHA, Default-Markierung, Schutzstatus und (wo die API es liefert) letztem Commit |
| Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| Commits | die letzten 15, mit Kurz-SHA, Nachricht, Autor, Datum und Link |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten |
Woher der CI-Status kommt:
| System | Quelle |
| --- | --- |
| GitHub | `actions/runs` für den Default-Branch; Lauf-Status und Ergebnis werden zusammengeführt |
| GitLab | `pipelines` für den Default-Branch |
| Gitea | Kombinierter Commit-Status — deckt Gitea Actions **und** externe Systeme wie Woodpecker ab, anders als ein reiner Actions-Endpunkt, den ältere Instanzen nicht kennen |
Zur Ehrlichkeit der Aktivitätskurve: geholt wird eine Seite mit 100 Commits. Reicht die
nicht über den ganzen Zeitraum, wird die Kurve auf den tatsächlich belegten Ausschnitt
gekürzt — eine Null zu zeichnen, wo wir schlicht nichts wissen, wäre falsch.
- **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.
@@ -253,42 +137,6 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
---
## 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
@@ -309,49 +157,21 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| Tabelle | Inhalt |
| --- | --- |
| `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 |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token |
| `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, Body auf
256 KB begrenzt.
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI.
- Prisma arbeitet ausschließlich mit parametrisierten Abfragen.
### 6.3 Betrieb
@@ -366,22 +186,7 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 6.4 Tests
`npm test` führt die Unit-Tests aus — reine Logik, kein DOM, keine Datenbank, unter einer
Sekunde:
| Bereich | Was geprüft wird |
| --- | --- |
| `utils` | Slug-Erzeugung inklusive Umlauten und Längenbegrenzung, Tag-Aufbereitung, Kürzen, Zahlenformat |
| `git/parse` | URL-Zerlegung für https, SSH, verschachtelte GitLab-Gruppen und Oberflächenpfade; Provider-Erkennung; Bildung der Aktivitätskurve |
| `security/ssrf` | Sperrlisten für Loopback, Link-Local, Metadaten-Endpunkt, CGNAT und Multicast; Formprüfung; die drei Umgebungsschalter |
| `auth/password` | scrypt-Rundlauf, unterschiedliches Salz, Ablageformat, Verhalten bei kaputten Hashes, Passwortregeln |
Bewusst ohne Datenbank- und Browsertests: die brauchen Infrastruktur und gehören in eine
CI-Pipeline. Das steht in der [ROADMAP](ROADMAP.md).
### 6.5 API
### 6.4 API
Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health.
Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
@@ -391,5 +196,12 @@ Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
## 7. Ideen für später
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.
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
+10 -185
View File
@@ -22,16 +22,10 @@ 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)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**.
---
@@ -112,55 +106,21 @@ 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 — 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.
> Sie müssen dann in den Verbindungen neu hinterlegt werden — die restlichen Daten bleiben
> unberührt.
---
## 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:
@@ -182,80 +142,8 @@ Nebula auffindbare Credentials nutzt.
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
```
@@ -298,8 +186,6 @@ nebula/
| `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
| `npm test` | Unit-Tests (Vitest), unter einer Sekunde |
| `npm run test:watch` | Tests im Beobachtungsmodus |
| `npm run typecheck` | TypeScript prüfen |
| `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
@@ -311,30 +197,10 @@ 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` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `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 |
@@ -354,36 +220,16 @@ curl -X POST http://localhost:3000/api/projects \
---
## 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:
## Sicherheitshinweise
- 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.
---
@@ -398,27 +244,6 @@ Grenzen, die du kennen solltest:
| 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 |
---
-139
View File
@@ -1,139 +0,0 @@
# 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 Tests ausbauen: Datenbank und Browser `L`
Das Fundament steht — 51 Unit-Tests der reinen Logik laufen in unter einer
Sekunde. Was fehlt, ist die Schicht darüber:
- **Integrationstests** gegen eine Wegwerf-Datenbank: Datentrennung zwischen
Konten, Import-Strategien, Backoff des Abgleichs
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung, Kanban
- **CI-Pipeline** bei GitHub oder Gitea Actions
Ich prüfe bislang alles von Hand, inklusive Screenshots. Das trägt nicht
dauerhaft und macht jede weitere Änderung teurer als nötig.
### 1.2 Benachrichtigungen `M`
Bei Sync-Fehlern, roter CI, neuen Releases oder Projekten, die lange
stillstehen. Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit
dem automatischen Abgleich und dem CI-Status ergibt das ein brauchbares
Frühwarnsystem — die Daten liegen bereits vor.
### 1.3 Aufgaben und Checklisten `M`
Eigene Entität statt Freitext, mit Häkchen und automatisch berechnetem
Fortschritt. Ersetzt das manuelle Schieben des Reglers. Die Markdown-
Checklisten in Notizen sind ein Vorgeschmack, aber nicht auswertbar.
---
## 2 · Danach
### 2.1 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.2 Commit-Aktivität als Heatmap `S`
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.3 Kanban mit freier Reihenfolge `M`
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
---
## 3 · Größere Brocken
### 3.1 Mehrere Repositories je Projekt `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
und Anpassungen in der Anzeige.
### 3.2 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.3 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.4 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 |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| 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. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| 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 | ✓ |
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
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.
-9
View File
@@ -52,16 +52,7 @@ 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
+3 -2723
View File
File diff suppressed because it is too large Load Diff
+3 -14
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "0.5.0",
"version": "0.1.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -23,27 +23,17 @@
"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",
"test": "vitest run",
"test:watch": "vitest"
"postinstall": "prisma generate"
},
"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": {
@@ -54,8 +44,7 @@
"prisma": "^6.8.2",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^4.1.10"
"typescript": "^5.8.3"
},
"engines": {
"node": ">=20.11"
@@ -1,143 +0,0 @@
-- 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;
@@ -1,11 +0,0 @@
-- 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;
@@ -1,16 +0,0 @@
-- Volltextsuche ueber Notizen.
--
-- Dies ist ein Ausdrucksindex. Prisma kann so etwas im Schema nicht abbilden
-- und kennt ihn deshalb nicht. Nachgeprueft: "prisma migrate diff" laesst ihn
-- in Ruhe und schlaegt kein DROP vor - Ausdrucksindizes werden schlicht
-- ignoriert. Trotzdem beim Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss mit dem in src/app/api/search/route.ts uebereinstimmen,
-- sonst greift der Index nicht. Die zweistellige Form von to_tsvector mit
-- fester Konfiguration ist IMMUTABLE und damit indizierbar.
CREATE INDEX "Note_fulltext_idx" ON "Note"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || "content"));
@@ -1,9 +0,0 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "ciBranch" TEXT,
ADD COLUMN "ciFinishedAt" TIMESTAMP(3),
ADD COLUMN "ciName" TEXT,
ADD COLUMN "ciState" TEXT,
ADD COLUMN "ciUrl" TEXT,
ADD COLUMN "commitActivity" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "issues" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "pullRequests" JSONB NOT NULL DEFAULT '[]';
@@ -1,28 +0,0 @@
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
"position" INTEGER NOT NULL DEFAULT 0,
"dueDate" TIMESTAMP(3),
"labels" TEXT[] DEFAULT ARRAY[]::TEXT[],
"doneAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+7 -202
View File
@@ -33,139 +33,6 @@ enum GitProvider {
GITEA
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// 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())
@@ -186,28 +53,20 @@ 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([ownerId, name])
@@unique([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
/// Pro Besitzer eindeutig – siehe @@unique unten.
slug String
slug String @unique
/// Einzeiler fuer die Karte im Dashboard.
summary String?
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
@@ -233,58 +92,16 @@ 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)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
notes Note[]
tasks Task[]
snapshot RepoSnapshot?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([connectionId])
@@index([updatedAt])
@@index([ownerId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
model Task {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
description String?
status TaskStatus @default(TODO)
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
position Int @default(0)
dueDate DateTime?
labels String[] @default([])
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, status, position])
}
/// Freie Notizen zu einem Projekt.
@@ -335,23 +152,11 @@ model RepoSnapshot {
/// Rohdaten der Listen – bewusst als JSON, damit providerspezifische
/// Zusatzfelder nicht verloren gehen.
branches Json @default("[]")
tags Json @default("[]")
commits Json @default("[]")
releases Json @default("[]")
languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
branches Json @default("[]")
tags Json @default("[]")
commits Json @default("[]")
releases Json @default("[]")
languages Json @default("{}")
pushedAt DateTime?
fetchedAt DateTime @default(now())
+2 -5
View File
@@ -86,12 +86,9 @@ 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.findFirst({
where: { slug: sample.slug, ownerId: null },
const existing = await prisma.project.findUnique({
where: { slug: sample.slug },
});
if (existing) {
console.log(` · uebersprungen (existiert): ${sample.slug}`);
-64
View File
@@ -1,64 +0,0 @@
<#
.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`""
-43
View File
@@ -1,43 +0,0 @@
#!/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\""
-62
View File
@@ -1,62 +0,0 @@
<#
.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
-57
View File
@@ -1,57 +0,0 @@
#!/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."
-65
View File
@@ -1,65 +0,0 @@
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>
);
}
-76
View File
@@ -1,76 +0,0 @@
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>
);
}
@@ -1,64 +0,0 @@
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 });
}
-71
View File
@@ -1,71 +0,0 @@
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;
}
-49
View File
@@ -1,49 +0,0 @@
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 });
}
-44
View File
@@ -1,44 +0,0 @@
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;
}
-89
View File
@@ -1,89 +0,0 @@
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" } },
tasks: { orderBy: [{ status: "asc" }, { position: "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",
},
});
}
-80
View File
@@ -1,80 +0,0 @@
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 });
}
-37
View File
@@ -1,37 +0,0 @@
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 });
}
-121
View File
@@ -1,121 +0,0 @@
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);
}
}
-85
View File
@@ -1,85 +0,0 @@
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 });
}
-86
View File
@@ -1,86 +0,0 @@
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;
}
-11
View File
@@ -1,11 +0,0 @@
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;
}
@@ -1,28 +0,0 @@
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);
}
}
@@ -1,54 +0,0 @@
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);
}
}
@@ -1,32 +0,0 @@
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);
}
}
@@ -1,47 +0,0 @@
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);
}
}
-88
View File
@@ -1,88 +0,0 @@
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;
}
-19
View File
@@ -1,19 +0,0 @@
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,
});
}
-78
View File
@@ -1,78 +0,0 @@
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;
}
+5 -18
View File
@@ -1,32 +1,19 @@
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) {
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);
export async function POST(_request: Request, { params }: Params) {
const { id } = await params;
const connection = await prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
const connection = await prisma.gitConnection.findUnique({ where: { id } });
if (!connection) {
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
}
const result = await clientForConnection(connection).check();
+4 -17
View File
@@ -1,9 +1,6 @@
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";
@@ -11,23 +8,13 @@ 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.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!connection) return jsonError("Verbindung nicht gefunden", 404);
const connection = await prisma.gitConnection.findUnique({ where: { id } });
if (!connection) {
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
}
try {
const repos = await clientForConnection(connection).listRepos(query);
+23 -59
View File
@@ -1,11 +1,8 @@
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";
@@ -18,21 +15,16 @@ const safeSelect = {
};
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Verbindung 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 = connectionUpdateSchema.safeParse(body.data);
const parsed = connectionUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
@@ -44,24 +36,9 @@ 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
@@ -75,38 +52,25 @@ export async function PATCH(request: Request, { params }: Params) {
}
}
if (data.name !== undefined) {
const duplicate = await prisma.gitConnection.findFirst({
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
select: { id: true },
try {
const connection = await prisma.gitConnection.update({
where: { id },
data: update,
select: safeSelect,
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
return NextResponse.json({ connection });
} catch {
return NextResponse.json({ error: "Verbindung nicht gefunden oder Name vergeben" }, { status: 404 });
}
const connection = await prisma.gitConnection.update({
where: { id },
data: update,
select: safeSelect,
});
return NextResponse.json({ connection });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
const result = await prisma.gitConnection.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Verbindung nicht gefunden", 404);
return NextResponse.json({ ok: true });
try {
// Projekte bleiben erhalten, connectionId wird per onDelete: SetNull geleert.
await prisma.gitConnection.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Verbindung nicht gefunden" }, { status: 404 });
}
}
+9 -44
View File
@@ -1,12 +1,8 @@
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";
@@ -18,11 +14,7 @@ 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" },
});
@@ -30,20 +22,14 @@ export async function GET() {
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
}
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);
const parsed = connectionCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
@@ -53,37 +39,16 @@ export async function POST(request: Request) {
const { name, provider, baseUrl, username, token } = parsed.data;
// 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 },
});
const duplicate = await prisma.gitConnection.findUnique({ where: { name } });
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),
+26 -38
View File
@@ -1,33 +1,22 @@
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);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
}
const parsed = noteUpdateSchema.safeParse(body.data);
const parsed = noteUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
@@ -35,28 +24,27 @@ export async function PATCH(request: Request, { params }: Params) {
);
}
const updated = await prisma.note.update({
where: { id },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
},
});
return NextResponse.json({ note: updated });
try {
const note = await prisma.note.update({
where: { id },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
},
});
return NextResponse.json({ note });
} catch {
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
}
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
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 });
try {
await prisma.note.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Notiz nicht gefunden" }, { status: 404 });
}
}
-57
View File
@@ -1,57 +0,0 @@
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" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "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",
},
});
}
+11 -25
View File
@@ -1,30 +1,13 @@
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" }],
@@ -33,18 +16,21 @@ 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;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
const project = await prisma.project.findUnique({ where: { id }, select: { id: true } });
if (!project) {
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
}
const parsed = noteCreateSchema.safeParse(body.data);
const parsed = noteCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
+24 -54
View File
@@ -1,9 +1,7 @@
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";
@@ -11,32 +9,29 @@ 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.findFirst({
where: { id, ownerId: auth.user.id },
const project = await prisma.project.findUnique({
where: { id },
include: projectDetailInclude,
});
// 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);
if (!project) {
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 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;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
}
const parsed = projectUpdateSchema.safeParse(body.data);
const parsed = projectUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
@@ -44,39 +39,19 @@ export async function PATCH(request: Request, { params }: Params) {
);
}
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 existing = await prisma.project.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined && data.name !== existing.name) {
update.name = data.name;
update.slug = await uniqueSlug(data.name, auth.user.id, id);
update.slug = await uniqueSlug(data.name, id);
}
for (const key of [
"summary",
"description",
"status",
"priority",
"progress",
"accent",
"tags",
"favorite",
"autoSync",
"connectionId",
] as const) {
for (const key of ["summary", "description", "status", "priority", "progress", "accent", "tags", "favorite", "connectionId"] as const) {
if (data[key] !== undefined) update[key] = data[key];
}
@@ -104,16 +79,11 @@ 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;
// 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 });
try {
await prisma.project.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ error: "Projekt nicht gefunden" }, { status: 404 });
}
}
+3 -25
View File
@@ -1,9 +1,6 @@
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";
@@ -11,31 +8,12 @@ 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) {
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);
export async function POST(_request: Request, { params }: Params) {
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.findFirst({
where: { id, ownerId: auth.user.id },
const project = await prisma.project.findUnique({
where: { id },
include: projectDetailInclude,
});
@@ -1,93 +0,0 @@
import { NextResponse } from "next/server";
import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Neue Reihenfolge nach dem Ziehen.
*
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
* und ein verlorener Aufruf laesst sich einfach erneut senden.
*/
export async function PATCH(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 body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
const owned = await prisma.task.findMany({
where: { projectId: id, id: { in: requestedIds } },
select: { id: true, status: true },
});
const ownedIds = new Set(owned.map((task) => task.id));
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
data: {
status,
position: index,
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
// immer wieder erneuert.
...(before !== status
? { doneAt: status === "DONE" ? now : null }
: {}),
},
}),
);
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await refreshDerivedProgress(id);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({
tasks,
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
-69
View File
@@ -1,69 +0,0 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskCreateSchema } from "@/lib/validation";
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;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({ tasks });
}
export async function POST(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 body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const task = await prisma.task.create({
data: {
projectId: id,
title,
description: description ?? null,
status,
// Neue Aufgaben landen unten in ihrer Spalte.
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
doneAt: status === "DONE" ? new Date() : null,
},
});
await refreshDerivedProgress(id);
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
return NextResponse.json({ task }, { status: 201 });
}
-29
View File
@@ -1,29 +0,0 @@
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",
},
});
}
-240
View File
@@ -1,240 +0,0 @@
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,
progressFromTasks: incoming.progressFromTasks,
};
const tasks = incoming.tasks.map((task, index) => ({
title: task.title,
description: task.description ?? null,
status: task.status,
// Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr.
position: task.position ?? index,
dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate))
? new Date(task.dueDate)
: null,
labels: task.labels,
doneAt: task.status === "DONE" ? new Date() : null,
...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt))
? { createdAt: new Date(task.createdAt) }
: {}),
}));
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.task.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
}),
]);
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 },
tasks: { create: tasks },
},
});
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 });
}
+8 -33
View File
@@ -1,26 +1,18 @@
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
? {
@@ -40,20 +32,14 @@ export async function GET(request: Request) {
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Ungueltiger JSON-Body" }, { status: 400 });
}
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);
const parsed = projectCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
@@ -63,15 +49,6 @@ 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;
@@ -85,9 +62,8 @@ 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, auth.user.id),
slug: await uniqueSlug(data.name),
summary: data.summary ?? null,
description: data.description ?? null,
status: data.status,
@@ -96,7 +72,6 @@ export async function POST(request: Request) {
accent: data.accent,
tags: data.tags,
favorite: data.favorite,
autoSync: data.autoSync,
repoUrl: data.repoUrl ?? null,
repoOwner,
repoName,
-67
View File
@@ -1,67 +0,0 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Hit {
projectId: string;
noteId: string;
title: string | null;
/** Textausschnitt mit hervorgehobener Fundstelle. */
snippet: string;
}
/**
* Volltextsuche ueber Notizen.
*
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt:
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank.
*
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) {
return NextResponse.json({ query, hits: [], projectIds: [] });
}
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
// Anfuehrungszeichen und wirft bei Unsinn keinen Fehler.
const hits = await prisma.$queryRaw<Hit[]>`
SELECT
n."projectId" AS "projectId",
n."id" AS "noteId",
n."title" AS "title",
ts_headline(
'german',
n."content",
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Note" n
JOIN "Project" p ON p."id" = n."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(n."title", '') || ' ' || n."content")
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(n."title", '') || ' ' || n."content"),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({
query,
hits,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden.
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))),
});
}
-75
View File
@@ -1,75 +0,0 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Eine Aufgabe gehoert dem, dem das zugehoerige Projekt gehoert. */
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true },
});
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels;
if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
if (data.status !== undefined && data.status !== existing.status) {
update.status = data.status;
// Beim Spaltenwechsel ans Ende der neuen Spalte.
update.position = await nextPosition(existing.projectId, data.status);
// doneAt nur setzen, wenn die Aufgabe wirklich nach DONE wandert.
update.doneAt = data.status === "DONE" ? new Date() : null;
}
const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ task });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ ok: true });
}
-395
View File
@@ -1,395 +0,0 @@
/* ─────────────────────────────────────────────────────────────
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
View File
@@ -1,6 +1,5 @@
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";
@@ -9,14 +8,11 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Git-Verbindungen",
description: "GitHub, GitLab und Gitea verbinden.",
description: "GitHub, GitLab und Gitea mit Nebula 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" },
});
+207 -49
View File
@@ -1,14 +1,8 @@
@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 {
@@ -31,50 +25,22 @@
--glass-highlight: rgba(255, 255, 255, 0.16);
}
@layer base {
* {
border-color: var(--glass-border);
}
* {
border-color: var(--glass-border);
}
html,
body {
min-height: 100%;
}
html,
body {
min-height: 100%;
}
body {
background-color: var(--color-void);
color: #e6e8f5;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
::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;
}
body {
background-color: var(--color-void);
color: #e6e8f5;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
/* ── Hintergrund: langsam driftende Nebel ───────────────────── */
@@ -159,7 +125,176 @@
to { transform: translate3d(4rem, -6rem, 0) scale(1.1); }
}
/* ── Bewegung & Effekte ─────────────────────────────────────── */
/* ── 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 ──────────────────────────────────────────────── */
.shimmer {
background: linear-gradient(
@@ -196,6 +331,29 @@
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,
+15 -25
View File
@@ -1,26 +1,16 @@
import type { Metadata, Viewport } from "next";
import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session";
import { APP_NAME, APP_TAGLINE, APP_VERSION } from "@/lib/config";
import "./globals.css";
/**
* generateMetadata statt eines konstanten metadata-Objekts: nur so wird
* APP_NAME zur Laufzeit gelesen. Ein konstanter Export waere beim Build
* eingefroren und liesse sich im fertigen Docker-Image nicht mehr aendern.
*/
export async function generateMetadata(): Promise<Metadata> {
return {
title: {
default: `${APP_NAME} – Vibe Coding Dashboard`,
template: `%s · ${APP_NAME}`,
},
description:
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME,
robots: { index: false, follow: false },
};
}
export const metadata: Metadata = {
title: {
default: "Nebula – Vibe Coding Dashboard",
template: "%s · Nebula",
},
description:
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: "Nebula",
};
export const viewport: Viewport = {
themeColor: "#05060f",
@@ -28,11 +18,9 @@ export const viewport: Viewport = {
initialScale: 1,
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const user = await getSessionUser();
return (
<html lang="de" suppressHydrationWarning>
<body className="min-h-screen antialiased">
@@ -43,7 +31,7 @@ export default async function RootLayout({
</div>
<div className="aurora-grid" aria-hidden="true" />
<TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} />
<TopNav />
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
{children}
@@ -51,8 +39,10 @@ export default async 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>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v{APP_VERSION}</span>
<span>
Nebula · lokal betrieben, Daten bleiben in deiner PostgreSQL.
</span>
<span className="font-mono text-slate-600">v0.1.0</span>
</div>
</footer>
</body>
-44
View File
@@ -1,44 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { LoginForm } from "@/components/auth/LoginForm";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME, WEBAUTHN_AVAILABLE } from "@/lib/config";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Anmelden" };
}
type PageProps = { searchParams: Promise<{ weiter?: string }> };
export default async function LoginPage({ searchParams }: PageProps) {
const [user, state, params] = await Promise.all([
getSessionUser(),
getInstanceState(),
searchParams,
]);
if (state.needsSetup) redirect("/setup");
if (user) redirect("/");
// Nur relative Pfade zulassen – sonst waere das ein offener Redirect.
const raw = params.weiter ?? "/";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
return (
<AuthShell
appName={APP_NAME}
title="Willkommen zurueck"
subtitle="Melde dich an, um deine Projekte zu sehen."
>
<LoginForm
passkeysAvailable={WEBAUTHN_AVAILABLE}
registrationOpen={state.registrationOpen}
next={next}
/>
</AuthShell>
);
}
+1 -8
View File
@@ -1,7 +1,6 @@
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";
@@ -10,16 +9,12 @@ 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" },
}),
@@ -63,9 +58,7 @@ export default async function DashboardPage() {
<StatCard
label="Ideen"
value={ideas}
// Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
// der schmalen Kachel mitten im Wort abgeschnitten.
hint="Noch nichts gebaut"
hint={STATUS_META.IDEA.description}
icon={Lightbulb}
color={STATUS_META.IDEA.color}
/>
+7 -28
View File
@@ -2,12 +2,8 @@ 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 { TaskBoard } from "@/components/tasks/TaskBoard";
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";
@@ -17,12 +13,9 @@ 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.findFirst({
where: { id, ownerId: user.id },
const project = await prisma.project.findUnique({
where: { id },
select: { name: true, summary: true },
});
return {
@@ -32,21 +25,11 @@ 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([
// 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" },
}),
prisma.project.findUnique({ where: { id }, include: projectDetailInclude }),
prisma.gitConnection.findMany({ select: connectionSafeSelect, orderBy: { name: "asc" } }),
]);
if (!project) notFound();
@@ -60,12 +43,6 @@ export default async function ProjectPage({ params }: PageProps) {
connections={connections as unknown as ConnectionSafe[]}
/>
<TaskBoard
projectId={detail.id}
tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks}
/>
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5">
<section className="glass p-5">
@@ -74,7 +51,9 @@ export default async function ProjectPage({ params }: PageProps) {
Beschreibung
</h2>
{detail.description ? (
<Markdown className="mt-3">{detail.description}</Markdown>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{detail.description}
</p>
) : (
<p className="mt-3 text-sm text-slate-500">
Keine Beschreibung hinterlegt. Ueber „Bearbeiten“ kannst du die Idee ausformulieren.
-31
View File
@@ -1,31 +0,0 @@
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>
);
}
-44
View File
@@ -1,44 +0,0 @@
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>
);
}
-90
View File
@@ -1,90 +0,0 @@
import { CircleCheck, CircleDashed, CircleSlash, CircleX, LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
const META: Record<
CiState,
{ label: string; short: string; className: string; icon: typeof CircleCheck; spin?: boolean }
> = {
success: {
label: "CI erfolgreich",
short: "CI ok",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
icon: CircleCheck,
},
failure: {
label: "CI fehlgeschlagen",
short: "CI rot",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
icon: CircleX,
},
running: {
label: "CI laeuft",
short: "CI laeuft",
className: "bg-amber-500/12 text-amber-200 ring-amber-400/30",
icon: LoaderCircle,
spin: true,
},
pending: {
label: "CI wartet",
short: "CI wartet",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
icon: CircleDashed,
},
cancelled: {
label: "CI abgebrochen",
short: "CI abgebrochen",
className: "bg-slate-500/12 text-slate-400 ring-slate-400/20",
icon: CircleSlash,
},
};
export function CiBadge({
state,
name,
url,
compact = false,
}: {
state: string | null | undefined;
name?: string | null;
url?: string | null;
compact?: boolean;
}) {
if (!state || !(state in META)) return null;
const meta = META[state as CiState];
const Icon = meta.icon;
const content = (
<>
<Icon className={cn("h-3 w-3 shrink-0", meta.spin && "animate-spin")} />
{compact ? meta.short : (name ?? meta.label)}
</>
);
const className = cn(
"inline-flex items-center gap-1.5 rounded-full ring-1 ring-inset",
meta.className,
compact ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-[11px]",
);
if (url && !compact) {
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className={cn(className, "transition-all hover:brightness-125")}
title={meta.label}
>
{content}
</a>
);
}
return (
<span className={className} title={name ? `${meta.label} · ${name}` : meta.label}>
{content}
</span>
);
}
+9 -109
View File
@@ -3,9 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Activity,
AlertTriangle,
CircleDot,
ExternalLink,
GitBranch,
GitCommitHorizontal,
@@ -20,8 +18,6 @@ import {
Star,
Tag,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { asArray, asRecord } from "@/types";
import type {
BranchInfo,
@@ -29,13 +25,10 @@ import type {
ProjectDetail,
ReleaseInfo,
TagInfo,
TicketInfo,
} from "@/types";
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
const TABS = [
{ key: "pulls", label: "PRs", icon: GitPullRequest },
{ key: "issues", label: "Issues", icon: CircleDot },
{ key: "branches", label: "Branches", icon: GitBranch },
{ key: "tags", label: "Tags", icon: Tag },
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
@@ -46,7 +39,7 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) {
const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls");
const [tab, setTab] = useState<TabKey>("branches");
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
@@ -56,9 +49,6 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const commits = asArray<CommitInfo>(snapshot?.commits);
const releases = asArray<ReleaseInfo>(snapshot?.releases);
const languages = asRecord(snapshot?.languages);
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
@@ -128,7 +118,6 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt
@@ -235,24 +224,6 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div>
</div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="glass p-4">
<div className="mb-2.5 flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Activity className="h-3 w-3" />
{/* Der Zeitraum richtet sich danach, wie weit die geholten
Commits zurueckreichen – hoechstens 30 Tage. */}
Commits der letzten {activity.length} Tage
</p>
<span className="text-xs tabular-nums text-slate-400">
{activity.reduce((sum, value) => sum + value, 0)}
</span>
</div>
<Sparkline values={activity} height={40} />
</div>
) : null}
{/* Sprachen */}
{languageTotal > 0 ? (
<div className="glass p-4">
@@ -292,14 +263,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<div className="glass overflow-hidden">
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
{TABS.map(({ key, label, icon: Icon }) => {
const count = {
pulls: pulls.length,
issues: issues.length,
branches: branches.length,
tags: tags.length,
commits: commits.length,
releases: releases.length,
}[key];
const count =
key === "branches"
? branches.length
: key === "tags"
? tags.length
: key === "commits"
? commits.length
: releases.length;
return (
<button
key={key}
@@ -321,16 +292,6 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div>
<div className="max-h-[26rem] overflow-y-auto">
{tab === "pulls" ? (
<TicketList
tickets={pulls}
empty="Keine offenen Pull oder Merge Requests."
icon={GitPullRequest}
/>
) : null}
{tab === "issues" ? (
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null}
{tab === "commits" ? <CommitList commits={commits} /> : null}
@@ -381,67 +342,6 @@ function Empty({ text }: { text: string }) {
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
}
function TicketList({
tickets,
empty,
icon: Icon,
}: {
tickets: TicketInfo[];
empty: string;
icon: typeof GitPullRequest;
}) {
if (tickets.length === 0) return <Empty text={empty} />;
return (
<div>
{tickets.map((ticket) => (
<Row key={`${ticket.number}-${ticket.title}`}>
<div className="flex min-w-0 items-start gap-2">
<Icon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
ticket.draft ? "text-slate-600" : "text-emerald-400/70",
)}
/>
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">
{ticket.title}
{ticket.draft ? (
<span className="ml-1.5 rounded bg-slate-500/15 px-1 py-0.5 text-[10px] text-slate-400">
Entwurf
</span>
) : null}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
<span className="font-mono text-slate-600">#{ticket.number}</span>
<span>{ticket.author ?? "unbekannt"}</span>
{ticket.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
{label}
</span>
))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
{ticket.url ? (
<a
href={ticket.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Im Git-System oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
function BranchList({ branches }: { branches: BranchInfo[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return (
-326
View File
@@ -1,326 +0,0 @@
"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>;
}
-276
View File
@@ -1,276 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
closestCorners,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
/**
* Statusspalten mit Ziehen und Ablegen.
*
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert;
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden.
*/
export function KanbanBoard({
projects,
showArchived,
}: {
projects: ProjectListItem[];
showArchived: boolean;
}) {
const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null);
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine
// oertliche Ausnahme mehr.
useEffect(() => {
setOverrides((current) => {
const next: Record<string, ProjectStatus> = {};
for (const [id, status] of Object.entries(current)) {
const project = projects.find((entry) => entry.id === id);
if (project && project.status !== status) next[id] = status;
}
return Object.keys(next).length === Object.keys(current).length ? current : next;
});
}, [projects]);
const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
// nicht mehr anklicken.
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status;
const columns = useMemo(() => {
const visible = STATUS_ORDER.filter(
(status) => showArchived || status !== "ARCHIVED",
);
return visible.map((status) => ({
status,
items: projects.filter((project) => statusOf(project) === status),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, overrides, showArchived]);
const active = activeId ? projects.find((project) => project.id === activeId) : null;
function onDragStart(event: DragStartEvent) {
setActiveId(String(event.active.id));
}
async function onDragEnd(event: DragEndEvent) {
setActiveId(null);
const id = String(event.active.id);
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null;
if (!target) return;
const project = projects.find((entry) => entry.id === id);
if (!project) return;
const previous = statusOf(project);
if (previous === target) return;
setOverrides((current) => ({ ...current, [id]: target }));
try {
const response = await fetch(`/api/projects/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
setOverrides((current) => {
const next = { ...current };
delete next[id];
return next;
});
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)}
>
<div className="flex gap-3 overflow-x-auto pb-3">
{columns.map((column) => (
<Column key={column.status} status={column.status} count={column.items.length}>
{column.items.map((project) => (
<KanbanCard key={project.id} project={project} />
))}
</Column>
))}
</div>
{/* Die mitgezogene Karte haengt am Cursor, nicht in der Spalte. */}
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <KanbanCard project={active} overlay /> : null}
</DragOverlay>
</DndContext>
);
}
function Column({
status,
count,
children,
}: {
status: ProjectStatus;
count: number;
children: React.ReactNode;
}) {
const meta = STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<section
ref={setNodeRef}
// Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status}
className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<header className="flex items-center gap-2 px-3 py-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h3>
<span className="ml-auto rounded-full bg-white/6 px-1.5 py-0.5 text-[10px] tabular-nums text-slate-400">
{count}
</span>
</header>
<div className="flex min-h-[7rem] flex-1 flex-col gap-2 px-2 pb-2">
{children}
{count === 0 ? (
<p
className={cn(
"rounded-xl border border-dashed border-white/8 px-3 py-6 text-center text-[11px] text-slate-600 transition-colors",
isOver && "border-violet-400/40 text-violet-200",
)}
>
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</section>
);
}
function KanbanCard({
project,
overlay = false,
}: {
project: ProjectListItem;
overlay?: boolean;
}) {
const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id });
return (
<article
ref={overlay ? undefined : setNodeRef}
className={cn(
"popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
<div className="flex items-start gap-2">
{/* Nur der Griff zieht – so bleibt der Titel ein normaler Link. */}
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-600 transition-colors hover:text-slate-300 active:cursor-grabbing"
aria-label={`${project.name} verschieben`}
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1">
<Link
href={`/projects/${project.id}`}
className="block truncate text-sm font-medium text-slate-100 hover:text-violet-200"
>
{project.name}
</Link>
{project.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-slate-500">
{truncate(project.summary, 90)}
</p>
) : null}
</div>
</div>
<div className="mt-2.5 h-1 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
<CiBadge state={project.snapshot?.ciState} compact />
{project.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" />
{project.snapshot.defaultBranch}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
<span className="ml-auto tabular-nums">{project.progress}%</span>
</div>
</article>
);
}
-55
View File
@@ -1,55 +0,0 @@
"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 -3
View File
@@ -4,7 +4,6 @@ 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. */
@@ -119,7 +118,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…\n\nMarkdown wird dargestellt:\n- [ ] offene Aufgabe\n**fett**, `code`, [Link](https://…)"}
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
value={content}
onChange={(event) => setContent(event.target.value)}
autoFocus
@@ -162,7 +161,9 @@ export function NotesPanel({ projectId, notes }: { projectId: string; notes: Not
{note.title}
</h3>
) : null}
<Markdown className="mt-1">{note.content}</Markdown>
<p className="mt-1 whitespace-pre-wrap break-words text-sm leading-relaxed text-slate-300">
{note.content}
</p>
<p className="mt-2.5 text-[11px] text-slate-600" suppressHydrationWarning>
{formatDateTime(note.createdAt)}
{wasEdited(note) ? ` · bearbeitet ${formatRelative(note.updatedAt)}` : null}
+5 -204
View File
@@ -1,58 +1,16 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
Columns3,
Database,
Download,
LayoutGrid,
ListFilter,
NotebookPen,
Plus,
Rows3,
Search,
Upload,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
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";
interface NoteHit {
projectId: string;
noteId: string;
title: string | null;
/** Ausschnitt mit [[…]] um die Fundstelle – siehe /api/search. */
snippet: string;
}
/**
* Wandelt die [[…]]-Markierungen von ts_headline in hervorgehobene Stellen.
* Bewusst kein HTML aus der Datenbank einbetten – der Text stammt aus einer
* Notiz und damit vom Benutzer.
*/
function highlight(snippet: string): React.ReactNode[] {
return snippet.split(/(\[\[.*?\]\])/g).map((part, index) => {
if (part.startsWith("[[") && part.endsWith("]]")) {
return (
<mark key={index} className="rounded bg-violet-500/25 px-0.5 text-violet-100">
{part.slice(2, -2)}
</mark>
);
}
return <span key={index}>{part}</span>;
});
}
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban";
type ViewMode = "grid" | "grouped";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
@@ -75,48 +33,6 @@ 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 [noteHits, setNoteHits] = useState<NoteHit[]>([]);
/**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
* unnoetig aufblaehen. Stattdessen sucht Postgres serverseitig, sobald der
* Begriff lang genug ist. Entprellt, damit nicht jeder Tastendruck eine
* Abfrage ausloest.
*/
useEffect(() => {
const needle = query.trim();
if (needle.length < 2) {
setNoteHits([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(needle)}`, {
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
setNoteHits(body.hits ?? []);
} catch {
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
const noteHitProjectIds = useMemo(
() => new Set(noteHits.map((hit) => hit.projectId)),
[noteHits],
);
const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>();
@@ -140,9 +56,7 @@ export function ProjectBoard({
(project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle) ||
// Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id)
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle)
);
});
@@ -163,7 +77,7 @@ export function ProjectBoard({
});
return sorted;
}, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]);
}, [projects, query, statusFilter, sort, showArchived]);
const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({
@@ -249,33 +163,8 @@ export function ProjectBoard({
>
<Rows3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</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>
@@ -339,54 +228,9 @@ export function ProjectBoard({
</div>
</div>
{/* Treffer in Notizen */}
{noteHits.length > 0 ? (
<div className="glass fade-up p-4">
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<NotebookPen className="h-3.5 w-3.5 text-violet-300" />
Treffer in Notizen
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
{noteHits.length}
</span>
</h2>
<ul className="space-y-1">
{noteHits.slice(0, 8).map((hit) => {
const project = projects.find((entry) => entry.id === hit.projectId);
return (
<li key={hit.noteId}>
<Link
href={`/projects/${hit.projectId}`}
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
>
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm text-slate-200">
{project?.name ?? "Projekt"}
</span>
{hit.title ? (
<span className="text-[11px] text-slate-500">· {hit.title}</span>
) : null}
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
{highlight(hit.snippet)}
</span>
</Link>
</li>
);
})}
</ul>
{noteHits.length > 8 ? (
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
… und {noteHits.length - 8} weitere
</p>
) : null}
</div>
) : null}
{/* Inhalt */}
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} />
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
@@ -423,54 +267,11 @@ 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>
);
}
+1 -24
View File
@@ -4,19 +4,16 @@ import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
Tag,
} from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils";
import { asArray, type ProjectListItem } from "@/types";
import type { ProjectListItem } from "@/types";
const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub",
@@ -32,7 +29,6 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot;
const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
@@ -114,17 +110,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</div>
</div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="mt-3">
<Sparkline values={activity} height={20} />
</div>
) : null}
{/* Git-Kurzinfo */}
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
{project.connection ? (
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1">
<span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
@@ -156,16 +143,6 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span>
) : null}
{project._count.tasks > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={`${project.tasks.length} von ${project._count.tasks} Aufgaben erledigt`}
>
<CheckSquare className="h-3.5 w-3.5 text-slate-500" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" />
+1 -42
View File
@@ -31,8 +31,6 @@ interface FormState {
repoUrl: string;
repoOwner: string;
repoName: string;
autoSync: boolean;
progressFromTasks: boolean;
}
const EMPTY: FormState = {
@@ -49,8 +47,6 @@ const EMPTY: FormState = {
repoUrl: "",
repoOwner: "",
repoName: "",
autoSync: true,
progressFromTasks: false,
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -91,8 +87,6 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoUrl: project.repoUrl ?? "",
repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "",
autoSync: project.autoSync,
progressFromTasks: project.progressFromTasks,
}
: EMPTY,
);
@@ -160,8 +154,6 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoUrl: form.repoUrl,
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
};
try {
@@ -295,25 +287,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5}
value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))}
disabled={form.progressFromTasks}
className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")}
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
className="w-full accent-violet-500"
/>
<label className="mt-2 flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.progressFromTasks}
onChange={(event) => update("progressFromTasks", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aus Aufgaben berechnen
<span className="mt-0.5 block text-[11px] text-slate-600">
Der Fortschritt ergibt sich dann aus dem Anteil erledigter Aufgaben und
laesst sich nicht mehr von Hand schieben.
</span>
</span>
</label>
</div>
<div className="sm:col-span-2">
@@ -438,22 +413,6 @@ 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 ? (
+23 -86
View File
@@ -1,16 +1,12 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
CheckSquare,
Download,
ExternalLink,
FileJson,
FileText,
Loader2,
Pencil,
Star,
@@ -18,7 +14,6 @@ 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";
@@ -35,8 +30,6 @@ 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);
@@ -169,17 +162,6 @@ 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"
@@ -208,77 +190,32 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */}
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1.5">
Fortschritt
{project.progressFromTasks ? (
<span className="inline-flex items-center gap-1 rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
<CheckSquare className="h-2.5 w-2.5" />
aus Aufgaben
</span>
) : null}
</span>
<span>Fortschritt</span>
<span className="tabular-nums text-slate-300">{progress}%</span>
</div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
<input
type="range"
min={0}
max={100}
step={5}
value={progress}
onChange={(event) => setProgress(Number(event.target.value))}
onMouseUp={() => patch({ progress })}
onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen"
// Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier
// wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder
// ueberschrieben.
disabled={project.progressFromTasks}
className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")}
style={{ "--fill": `${progress}%` } as React.CSSProperties}
/>
<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>
<input
type="range"
min={0}
max={100}
step={5}
value={progress}
onChange={(event) => setProgress(Number(event.target.value))}
onMouseUp={() => patch({ progress })}
onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen"
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
/>
</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}
onClose={() => setEditOpen(false)}
-57
View File
@@ -1,57 +0,0 @@
import { cn } from "@/lib/utils";
/**
* Commit-Aktivitaet als kleine Balkenreihe.
*
* Bewusst als reines SVG ohne Diagrammbibliothek: es sind dreissig Werte,
* die Achsen, Legende und Tooltips einer Bibliothek waeren hier nur Ballast.
*/
export function Sparkline({
values,
className,
height = 28,
label = "Commits",
}: {
values: number[];
className?: string;
height?: number;
label?: string;
}) {
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
if (total === 0) return null;
const max = Math.max(...values);
const gap = 1;
const barWidth = 3;
const width = values.length * (barWidth + gap) - gap;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={cn("w-full", className)}
style={{ height }}
role="img"
aria-label={`${label} der letzten ${values.length} Tage: insgesamt ${total}`}
>
<title>{`${label} der letzten ${values.length} Tage: insgesamt ${total}`}</title>
{values.map((value, index) => {
// Auch ein einzelner Commit soll sichtbar sein, daher die Untergrenze.
const barHeight = value === 0 ? 1.5 : Math.max(3, (value / max) * height);
return (
<rect
key={index}
x={index * (barWidth + gap)}
y={height - barHeight}
width={barWidth}
height={barHeight}
rx={1}
className={value === 0 ? "fill-white/10" : "fill-violet-400/80"}
/>
);
})}
</svg>
);
}
+57 -50
View File
@@ -1,10 +1,9 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { 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";
@@ -23,7 +22,6 @@ 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();
@@ -56,12 +54,10 @@ 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);
@@ -86,49 +82,60 @@ export function StatusSelect({
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
<Popover
anchorRef={triggerRef}
open={open}
onClose={() => setOpen(false)}
role="listbox"
width={264}
>
{STATUS_ORDER.map((value) => {
const option = STATUS_META[value];
const active = value === current;
return (
<button
key={value}
type="button"
role="option"
aria-selected={active}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void select(value);
}}
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/6",
)}
>
<span
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: option.color }}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm text-slate-100">
{option.label}
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
{option.description}
</span>
</span>
</button>
);
})}
</Popover>
</>
{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
role="listbox"
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
>
{STATUS_ORDER.map((value) => {
const option = STATUS_META[value];
const active = value === current;
return (
<button
key={value}
type="button"
role="option"
aria-selected={active}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void select(value);
}}
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",
)}
>
<span
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: option.color }}
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2 text-sm text-slate-100">
{option.label}
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
{option.description}
</span>
</span>
</button>
);
})}
</div>
</>
) : null}
</div>
);
}
+25 -134
View File
@@ -1,19 +1,8 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
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 { usePathname } from "next/navigation";
import { GitBranch, LayoutDashboard, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
const LINKS = [
@@ -21,15 +10,7 @@ const LINKS = [
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
];
export function TopNav({
user,
appName,
tagline,
}: {
user: SessionUser | null;
appName: string;
tagline: string;
}) {
export function TopNav() {
const pathname = usePathname();
return (
@@ -41,125 +22,35 @@ export function TopNav({
</span>
<span className="flex flex-col leading-none">
<span className="text-gradient text-lg font-semibold tracking-tight">
{appName}
Nebula
</span>
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
{tagline}
Vibe Coding
</span>
</span>
</Link>
{user ? (
<>
<nav className="flex items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-white/8 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</Link>
);
})}
</nav>
<div className="ml-auto">
<UserMenu user={user} />
</div>
</>
) : null}
<nav className="flex items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-white/8 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</Link>
);
})}
</nav>
</div>
</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>
</>
);
}
-530
View File
@@ -1,530 +0,0 @@
"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;
}
-975
View File
@@ -1,975 +0,0 @@
"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>
);
}
-48
View File
@@ -1,48 +0,0 @@
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>
);
}
-181
View File
@@ -1,181 +0,0 @@
"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>
);
}
-159
View File
@@ -1,159 +0,0 @@
"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>
);
}
-210
View File
@@ -1,210 +0,0 @@
"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>
);
}
-329
View File
@@ -1,329 +0,0 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, Loader2, Plus } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { TaskCard } from "@/components/tasks/TaskCard";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn } from "@/lib/utils";
type Columns = Record<TaskStatus, Task[]>;
function group(tasks: Task[]): Columns {
const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns;
for (const task of [...tasks].sort((a, b) => a.position - b.position)) {
columns[task.status].push(task);
}
return columns;
}
/**
* Aufgabenbrett eines Projekts.
*
* Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte
* hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen.
* Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue
* Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist
* wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus.
*/
export function TaskBoard({
projectId,
tasks,
progressFromTasks,
}: {
projectId: string;
tasks: Task[];
progressFromTasks: boolean;
}) {
const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks));
const [activeId, setActiveId] = useState<string | null>(null);
const [editing, setEditing] = useState<Task | null>(null);
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null);
const [saving, setSaving] = useState(false);
const dragging = useRef(false);
// Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst
// wuerde die Karte mitten in der Bewegung zurueckspringen.
useEffect(() => {
if (!dragging.current) setColumns(group(tasks));
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const allTasks = useMemo(() => Object.values(columns).flat(), [columns]);
const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null;
const total = allTasks.length;
const done = columns.DONE.length;
function containerOf(id: string): TaskStatus | null {
if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus;
for (const status of TASK_STATUS_ORDER) {
if (columns[status].some((task) => task.id === id)) return status;
}
return null;
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
/** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((task) => task.id === activeKey);
if (!moved) return current;
const targetIndex = current[to].findIndex((task) => task.id === overKey);
const insertAt = targetIndex >= 0 ? targetIndex : current[to].length;
return {
...current,
[from]: current[from].filter((task) => task.id !== activeKey),
[to]: [
...current[to].slice(0, insertAt),
{ ...moved, status: to },
...current[to].slice(insertAt),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const overContainer = containerOf(overKey);
if (container && container === overContainer) {
const list = columns[container];
const oldIndex = list.findIndex((task) => task.id === activeKey);
const newIndex = list.findIndex((task) => task.id === overKey);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const reordered = [...list];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
next = { ...columns, [container]: reordered };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
async function persist(next: Columns) {
setSaving(true);
try {
const payload = {
columns: Object.fromEntries(
TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]),
),
};
const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(tasks));
} finally {
setSaving(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<CheckSquare className="h-4 w-4" />
</span>
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgaben
{total > 0 ? (
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{done}/{total}
</span>
) : null}
{saving ? <Loader2 className="h-3 w-3 animate-spin text-slate-500" /> : null}
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{progressFromTasks
? "Der Fortschritt des Projekts folgt diesen Aufgaben."
: "Ziehen zum Verschieben und Sortieren."}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setCreatingIn("TODO")}
>
<Plus className="h-3.5 w-3.5" />
Aufgabe
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(tasks));
}}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{TASK_STATUS_ORDER.map((status) => (
<TaskColumn
key={status}
status={status}
tasks={columns[status]}
onAdd={() => setCreatingIn(status)}
onEdit={setEditing}
/>
))}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <TaskCard task={active} overlay /> : null}
</DragOverlay>
</DndContext>
<TaskDialog
open={creatingIn !== null || editing !== null}
projectId={projectId}
task={editing}
initialStatus={creatingIn ?? "TODO"}
onClose={() => {
setCreatingIn(null);
setEditing(null);
}}
/>
</section>
);
}
function TaskColumn({
status,
tasks,
onAdd,
onEdit,
}: {
status: TaskStatus;
tasks: Task[];
onAdd: () => void;
onEdit: (task: Task) => void;
}) {
const meta = TASK_STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div
ref={setNodeRef}
data-task-column={status}
className={cn(
"flex min-h-[8rem] flex-col rounded-xl border border-white/6 bg-black/20 p-2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<div className="mb-2 flex items-center gap-2 px-1">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{meta.label}
</h3>
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
<SortableContext
items={tasks.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
<p className="rounded-lg border border-dashed border-white/8 px-2 py-4 text-center text-[10px] text-slate-600">
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</SortableContext>
<button
type="button"
onClick={onAdd}
className="mt-1.5 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-white/5 hover:text-slate-300"
>
<Plus className="h-3 w-3" />
hinzufuegen
</button>
</div>
);
}
function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(isDragging && "opacity-30")}
>
<TaskCard task={task} onEdit={onEdit} dragHandle={{ ...attributes, ...listeners }} />
</div>
);
}
-96
View File
@@ -1,96 +0,0 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import type { Task } from "@prisma/client";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
function dueTone(due: Date | string | null, done: boolean) {
if (!due || done) return null;
const date = new Date(due);
if (Number.isNaN(date.getTime())) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const days = Math.round((target.getTime() - today.getTime()) / 86_400_000);
if (days < 0) return { className: "text-rose-300", label: "ueberfaellig" };
if (days === 0) return { className: "text-amber-300", label: "heute" };
if (days <= 3) return { className: "text-amber-200/70", label: null };
return { className: "text-slate-500", label: null };
}
export function TaskCard({
task,
onEdit,
dragHandle,
overlay = false,
}: {
task: Task;
onEdit?: (task: Task) => void;
dragHandle?: Record<string, unknown>;
overlay?: boolean;
}) {
const done = task.status === "DONE";
const due = dueTone(task.dueDate, done);
return (
<article
className={cn(
"group rounded-lg border border-white/8 bg-[#0b0d1c] p-2 transition-colors",
!overlay && "hover:border-white/16",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
>
<div className="flex items-start gap-1.5">
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-700 transition-colors hover:text-slate-400 active:cursor-grabbing"
aria-label={`${task.title} verschieben`}
{...dragHandle}
>
<GripVertical className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => onEdit?.(task)}
disabled={!onEdit}
className="min-w-0 flex-1 text-left"
>
<p
className={cn(
"break-words text-xs leading-snug text-slate-200",
done && "text-slate-500 line-through",
)}
>
{task.title}
</p>
{(task.description || task.dueDate || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null}
{task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
{due?.label ?? formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-slate-400">
{label}
</span>
))}
</div>
)}
</button>
</div>
</article>
);
}
-233
View File
@@ -1,233 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
function toDateInput(value: Date | string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
export function TaskDialog({
open,
projectId,
task,
initialStatus,
onClose,
}: {
open: boolean;
projectId: string;
task: Task | null;
initialStatus: TaskStatus;
onClose: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(task);
useEffect(() => {
if (!open) return;
setTitle(task?.title ?? "");
setDescription(task?.description ?? "");
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setError(null);
}, [open, task, initialStatus]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
const body = {
title,
description,
status,
// Ohne Uhrzeit auf Mittag legen, damit Zeitzonenverschiebungen das
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
};
try {
const response = await fetch(
isEdit ? `/api/tasks/${task?.id}` : `/api/projects/${projectId}/tasks`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
async function remove() {
if (!task) return;
if (!window.confirm(`Aufgabe „${task.title}“ loeschen?`)) return;
setBusy(true);
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
if (!response.ok) throw new Error("Loeschen fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
description={isEdit ? undefined : "Titel genuegt – alles andere ist optional."}
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="task-title">
Titel *
</label>
<input
id="task-title"
className="field"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={200}
autoFocus
/>
</div>
<div>
<span className="field-label">Spalte</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASK_STATUS_ORDER.map((value) => {
const meta = TASK_STATUS_META[value];
return (
<button
key={value}
type="button"
onClick={() => setStatus(value)}
className={cn(
"rounded-lg border px-2 py-2 text-xs transition-all",
status === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
/>
{meta.label}
</span>
</button>
);
})}
</div>
</div>
<div>
<label className="field-label" htmlFor="task-description">
Beschreibung
</label>
<textarea
id="task-description"
className="field min-h-24 resize-y font-mono text-[13px]"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Markdown wird dargestellt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-due">
Faellig am
</label>
<input
id="task-due"
className="field"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="task-labels">
Labels (kommagetrennt)
</label>
<input
id="task-labels"
className="field"
value={labels}
onChange={(event) => setLabels(event.target.value)}
placeholder="bug, ui"
/>
</div>
</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}
<div className="flex items-center justify-between gap-2 pt-1">
{isEdit ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Loeschen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy || !title.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+7 -21
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -15,14 +14,6 @@ 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,
@@ -32,10 +23,6 @@ export function Modal({
footer,
width = "lg",
}: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
@@ -50,22 +37,22 @@ export function Modal({
};
}, [open, onClose]);
if (!mounted || !open) return null;
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
return (
<div className="fixed inset-0 z-50 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]/85 backdrop-blur-sm"
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
"glass fade-up relative z-10 my-auto w-full p-6",
width === "md" && "max-w-lg",
width === "lg" && "max-w-2xl",
width === "xl" && "max-w-4xl",
@@ -92,7 +79,6 @@ export function Modal({
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
</div>
</div>,
document.body,
</div>
);
}
-141
View File
@@ -1,141 +0,0 @@
"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,
);
}
-22
View File
@@ -1,22 +0,0 @@
/**
* 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();
}
}
-10
View File
@@ -1,10 +0,0 @@
/**
* 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;
-62
View File
@@ -1,62 +0,0 @@
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;
}
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("ein-langes-testpasswort", hash)).resolves.toBe(true);
});
it("weist ein falsches Passwort ab", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("etwas-anderes", hash)).resolves.toBe(false);
});
it("erzeugt fuer dasselbe Passwort unterschiedliche Hashes", async () => {
// Unterschiedliches Salz – sonst waere an gleichen Hashes ablesbar,
// wer dasselbe Passwort benutzt.
const a = await hashPassword("ein-langes-testpasswort");
const b = await hashPassword("ein-langes-testpasswort");
expect(a).not.toBe(b);
});
it("haelt das erwartete Ablageformat ein", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
expect(hash).toMatch(/^scrypt\$\d+\$\d+\$\d+\$[\w-]+\$[\w-]+$/);
});
it("stuerzt bei kaputten oder fehlenden Hashes nicht ab", async () => {
await expect(verifyPassword("x", null)).resolves.toBe(false);
await expect(verifyPassword("x", "")).resolves.toBe(false);
await expect(verifyPassword("x", "voelliger-unsinn")).resolves.toBe(false);
await expect(verifyPassword("x", "scrypt$a$b$c$d$e")).resolves.toBe(false);
});
it("behandelt gleichwertige Unicode-Schreibweisen gleich", async () => {
// "é" als ein Zeichen und als e + Akzent kombiniert.
const hash = await hashPassword("passwort-café-lang");
await expect(verifyPassword("passwort-café-lang", hash)).resolves.toBe(true);
});
});
describe("validatePasswordStrength", () => {
it("nimmt ein ausreichend langes Passwort an", () => {
expect(validatePasswordStrength("ein-langes-testpasswort")).toBeNull();
});
it("weist zu kurze Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(PASSWORD_MIN_LENGTH - 1))).toMatch(
/mindestens/,
);
});
it("weist naheliegende Passwoerter ab", () => {
expect(validatePasswordStrength("password")).toBeTruthy();
expect(validatePasswordStrength("Passwort")).toBeTruthy();
});
it("weist reine Ziffernfolgen ab", () => {
expect(validatePasswordStrength("1234567890123")).toMatch(/Ziffern/);
});
it("weist uebermaessig lange Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
});
});
-110
View File
@@ -1,110 +0,0 @@
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;
}
-137
View File
@@ -1,137 +0,0 @@
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 } });
}
-43
View File
@@ -1,43 +0,0 @@
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,
};
}
-70
View File
@@ -1,70 +0,0 @@
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 };
}
-258
View File
@@ -1,258 +0,0 @@
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;
}
}
-91
View File
@@ -1,91 +0,0 @@
/**
* 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;
}
import packageJson from "../../package.json";
/**
* Version aus der package.json – eine einzige Quelle. Vorher stand sie
* zusaetzlich in der Fusszeile und lief prompt auseinander.
*
* Bewusst als Standardimport: ein benannter Import aus einer JSON-Datei
* loest in Webpack eine Warnung aus.
*/
export const APP_VERSION: string = packageJson.version;
/** 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(" ");
}
-89
View File
@@ -1,89 +0,0 @@
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 exportedTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
status: z.enum(["TODO", "DOING", "BLOCKED", "DONE"]).optional().default("TODO"),
position: z.number().int().min(0).optional().default(0),
dueDate: z.string().nullable().optional(),
labels: z.array(z.string().max(30)).max(8).optional().default([]),
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([]),
tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
progressFromTasks: z.boolean().optional().default(false),
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}`;
}
-182
View File
@@ -1,182 +0,0 @@
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
import { PRIORITY_META, statusOf } from "@/lib/status";
import { formatDate, formatDateTime } from "@/lib/utils";
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & {
notes: Note[];
tasks: Task[];
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(),
})),
tasks: project.tasks.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
position: task.position,
dueDate: task.dueDate?.toISOString() ?? null,
labels: task.labels,
createdAt: task.createdAt.toISOString(),
})),
progressFromTasks: project.progressFromTasks,
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" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "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",
};
const TASK_LABEL: Record<string, string> = {
TODO: "Offen",
DOING: "In Arbeit",
BLOCKED: "Blockiert",
DONE: "Erledigt",
};
/**
* 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.tasks.length) {
lines.push("## Aufgaben", "");
for (const status of ["TODO", "DOING", "BLOCKED", "DONE"] as const) {
const inColumn = project.tasks.filter((task) => task.status === status);
if (!inColumn.length) continue;
lines.push(`### ${TASK_LABEL[status]}`, "");
for (const task of inColumn) {
const box = status === "DONE" ? "[x]" : "[ ]";
const due = task.dueDate ? ` _(faellig ${formatDate(task.dueDate)})_` : "";
lines.push(`- ${box} ${task.title}${due}`);
}
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");
}

Some files were not shown because too many files have changed in this diff Show More