25 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 ca26b9d0ce docs: Benutzerverwaltung, Passkeys und Sicherheitsmassnahmen dokumentieren
README: Abschnitt zur ersten Anmeldung mit den beiden Betriebsarten und der
Einrichtung eines Passkeys, erweiterte Variablentabelle mit dem Hinweis, dass
Name und URL zur Laufzeit gelesen werden, vollstaendige API-Uebersicht sowie
ein Sicherheitsabschnitt, der auch die Grenzen benennt: kein HSTS, keine
TLS-Terminierung, Ratenbegrenzung nur im Arbeitsspeicher, kein
Passwort-Zuruecksetzen per E-Mail, und warum Anfragen ohne Origin- und
Sec-Fetch-Site-Header durchgelassen werden.

FEATURES: neues Kapitel zu Konten und Zugang, aktualisiertes Datenmodell und
ausgebauter Sicherheitsabschnitt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
Florian WildeandClaude Opus 5 475e544d77 chore: neue Umgebungsvariablen in .env.example und Compose
APP_NAME, APP_TAGLINE und APP_URL sowie SESSION_TTL_DAYS und die drei
Schalter des SSRF-Schutzes werden an den App-Container durchgereicht.

Bei APP_URL steht ausdruecklich dabei, dass daraus die WebAuthn-rpID
abgeleitet wird, dass Passkeys einen sicheren Kontext brauchen und dass ein
spaeterer Hostnamenwechsel bestehende Passkeys ungueltig macht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
Florian WildeandClaude Opus 5 6066164ce9 feat(security): alle Daten auf den Besitzer einschraenken
Bisher sah jede Anfrage alles. Jetzt traegt jede Abfrage eine
Besitzerbedingung, und zwar an erster Stelle in der where-Klausel statt als
nachtraeglicher Vergleich - so kann sie nicht versehentlich uebersprungen
werden.

- Lesen: findFirst statt findUnique, immer mit ownerId
- Schreiben und Loeschen: updateMany/deleteMany mit ownerId in der
  where-Klausel. Fremde Datensaetze werden dadurch gar nicht erst getroffen,
  und die betroffene Zeilenzahl sagt, ob es den Datensatz gab.
- Notizen haengen ueber project.ownerId am Besitzer
- Beim Verknuepfen eines Projekts wird geprueft, dass die Git-Verbindung dem
  Aufrufer gehoert - sonst liesse sich eine fremde Verbindung und damit ein
  fremder Token unterschieben
- Fremde IDs liefern 404 statt 403: die Antwort bestaetigt nicht einmal,
  dass es den Datensatz gibt
- Ausgehende Endpunkte (Sync, Verbindungstest, Repo-Liste) zusaetzlich
  ratenbegrenzt, Basis-URLs gegen den SSRF-Schutz geprueft
- Slug und Verbindungsname werden je Besitzer auf Eindeutigkeit geprueft

Nachgewiesen: ein zweites Konto erhaelt auf GET, PATCH, DELETE, Notiz
anlegen und Sync eines fremden Projekts durchgaengig 404, auf die
Admin-Endpunkte 403, und das Zielprojekt bleibt unveraendert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:59 +02:00
Florian WildeandClaude Opus 5 e273a0dcaa feat(admin): Kontoverwaltung und Admin-Einstellungen
"Mein Konto": Profil, Passwort setzen oder wechseln, Passkeys anlegen,
umbenennen und entfernen, aktive Sitzungen einsehen und ueberall abmelden.
Der letzte Passkey laesst sich nicht entfernen, solange kein Passwort
gesetzt ist - sonst waere das Konto ausgesperrt.

Admin-Einstellungen: Betriebsart umschalten, Selbstregistrierung freigeben,
Konten anlegen, bearbeiten, deaktivieren und loeschen. Die Instanzwerte aus
der .env werden angezeigt, damit sichtbar ist, warum Passkeys gegebenenfalls
nicht verfuegbar sind.

Schutzregeln:
- Der Wechsel in den Einzelbetrieb wird abgelehnt, solange mehr als ein
  Konto existiert.
- Der letzte aktive Administrator kann weder herabgestuft noch deaktiviert
  oder geloescht werden.
- Das eigene Konto laesst sich nicht loeschen oder deaktivieren.
- Deaktivieren oder ein neues Passwort beenden sofort alle Sitzungen des
  Kontos.

Administratoren verwalten Konten, keine Inhalte: fremde Projekte sehen auch
sie nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
Florian WildeandClaude Opus 5 36523a72d5 feat(auth): Einrichtung, Anmeldeseite und Registrierung
- /setup fuehrt beim ersten Aufruf durch das Anlegen des Administratorkontos
  und die Wahl der Betriebsart. Der Endpunkt funktioniert nur, solange es
  keinen Benutzer gibt; danach kann sich niemand mehr nachtraeglich ein
  Adminkonto erschleichen. Vorhandene Projekte ohne Besitzer werden dabei
  uebernommen und im Formular vorher angekuendigt.
- /login mit Passwort oder Passkey. Der Passkey-Knopf entfaellt, wenn APP_URL
  keinen sicheren Kontext ergibt - mit Hinweis, woran es liegt. Das Ziel nach
  der Anmeldung wird auf relative Pfade begrenzt, sonst waere der
  weiter-Parameter ein offener Redirect.
- /register nur im Mehrbenutzerbetrieb und nur, wenn ein Administrator die
  Selbstregistrierung freigegeben hat.
- Kopfzeile mit Benutzermenue: eigenes Konto, Admin-Einstellungen, Abmelden.
  Ohne Anmeldung bleibt sie auf das Logo reduziert.
- Layout liest Titel und Namen ueber generateMetadata, damit APP_NAME zur
  Laufzeit greift statt beim Build eingefroren zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:33 +02:00
Florian WildeandClaude Opus 5 37573962a8 feat(auth): Anmeldung mit Passwort und Passkey
Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
Florian WildeandClaude Opus 5 1e9f7bd45a feat(db): Datenmodell fuer Benutzer, Sitzungen und Passkeys
Neue Tabellen:
- User        Konto mit Rolle, optionalem Passwort-Hash, Sperrzaehler.
              Das Passwort ist optional, weil ein Konto auch ausschliesslich
              per Passkey nutzbar sein darf.
- Session     serverseitige Sitzung. Gespeichert wird nur der SHA-256-Hash
              des Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren
              Sitzungen ergibt.
- Credential  ein Passkey je Zeile, mit oeffentlichem Schluessel, Transporten
              und Signaturzaehler.
- WebAuthnChallenge  kurzlebig und einmalig verwendbar, deshalb in der
              Datenbank statt im Cookie.
- AppSettings  Betriebsart und Registrierung, genau eine Zeile.

Project und GitConnection bekommen einen Besitzer. Bewusst nullable: so
ueberstehen Bestandsdaten die Migration und werden beim Anlegen des ersten
Kontos uebernommen. Slug und Verbindungsname sind dadurch nur noch je
Besitzer eindeutig - zwei Personen duerfen beide ein Projekt "dashboard"
haben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:30:12 +02:00
Florian WildeandClaude Opus 5 e13c335697 feat(security): SSRF-Schutz, Ratenbegrenzung und harte Body-Pruefung
Die Basis-URL einer Git-Verbindung ist eine Benutzereingabe, die der Server
anschliessend selbst abruft. Ohne Pruefung liesse sich darueber jede vom
Server erreichbare Adresse ansprechen - Cloud-Metadaten unter
169.254.169.254, lokal gebundene Dienste oder ein Portscan im Heimnetz.

ssrf.ts prueft deshalb vor jedem ausgehenden Aufruf:
- Loopback, Link-Local samt Metadaten-Endpunkt, unspezifizierte Adressen,
  Multicast und CGNAT sind gesperrt
- das private LAN bleibt erlaubt, weil dort ueblicherweise die eigene
  Gitea-Instanz steht; abschaltbar ueber GIT_BLOCK_PRIVATE
- GIT_ALLOW_LOOPBACK fuer eine Instanz auf demselben Rechner,
  GIT_ALLOWED_HOSTS als strengster Modus
- Hostnamen werden aufgeloest und *jede* zurueckgegebene Adresse geprueft,
  sonst genuegte ein zweiter A-Record fuer DNS-Rebinding
- nach einem Redirect wird das tatsaechliche Ziel erneut geprueft

Dazu:
- rateLimit.ts, ein Zaehler im Arbeitsspeicher mit vorkonfigurierten
  Kontingenten fuer Anmeldung, ausgehende Aufrufe und WebAuthn
- request.ts liest JSON-Bodies nur mit Content-Type application/json und
  hoechstens 256 KB. Die Content-Type-Pflicht ist zugleich CSRF-Schutz in
  der Tiefe: ein Cross-Site-Formular kommt ohne Preflight nur mit
  text/plain, multipart oder urlencoded durch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
Florian WildeandClaude Opus 5 955e0cefbc fix(ui): Komponentenklassen in @layer components verschieben
Im Suchfeld lag das Lupensymbol auf dem Platzhaltertext. Ursache war die
Kaskade: .field steht mit der Kurzschreibweise "padding: .6rem .85rem" im
Stylesheet hinter Tailwinds Utilities, also gewann sie gegen "pl-9" und das
linke Padding blieb bei 13,6 Pixeln - zu wenig fuer das Symbol.

Die wiederkehrenden Bausteine liegen jetzt in einer eigenen Datei und dort in
@layer components. Tailwind sortiert die Layer als theme, base, components,
utilities; damit gewinnen Utilities wieder gegen die Komponentenklasse.

Das behebt nicht nur die Ueberlappung: auch alle Schaltflaechen mit
"btn ... px-2.5 py-1.5" wurden bisher in der Standardgroesse gerendert, weil
.btn ebenfalls eine padding-Kurzschreibweise setzt. Sie sind jetzt so
kompakt wie vorgesehen.

Geprueft am gebauten Stylesheet: .field liegt an Position 11107 im
components-Layer, .pl-9 an 31324 - die Utility gewinnt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:29:45 +02:00
Florian WildeandClaude Opus 5 a412963b5d docs: getestete Versionen und Hinweis zur Prisma-Warnung ergaenzen
Haelt fest, womit beide Betriebsarten tatsaechlich verifiziert wurden, und
erklaert die Deprecation-Warnung von Prisma 6.19 samt Grund, warum die
Umstellung auf prisma.config.ts noch aussteht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:11:52 +02:00
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
100 changed files with 14832 additions and 6 deletions
+78
View File
@@ -0,0 +1,78 @@
# ─────────────────────────────────────────────────────────────
# Nebula – Beispielkonfiguration
# Kopieren nach ".env" und Werte anpassen.
# (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.
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
# Im Docker-Vollbetrieb (--profile full) setzt compose DATABASE_URL selbst
# auf den Service-Hostnamen "db" – der Wert oben wird dann nicht verwendet.
# Zugangsdaten des Postgres-Containers aus docker-compose.yml
POSTGRES_USER="nebula"
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.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen.
SESSION_TTL_DAYS="30"
# ── Ausgehende Git-Zugriffe ──────────────────────────────────
# Timeout fuer Git-API-Aufrufe in Millisekunden
GIT_API_TIMEOUT_MS="15000"
# Schutz vor Server Side Request Forgery: die Basis-URL einer Git-Verbindung
# ist eine Benutzereingabe und wird vom Server abgerufen.
#
# Voreinstellung: Loopback, Link-Local (169.254.x, Cloud-Metadaten), Multicast
# und CGNAT sind gesperrt; das private LAN ist erlaubt, weil dort ueblicherweise
# die eigene Gitea-Instanz steht.
# Gitea laeuft auf demselben Rechner wie Nebula? Dann auf true setzen.
# GIT_ALLOW_LOOPBACK="false"
# Kein Zugriff ins private Netz (10/8, 172.16/12, 192.168/16, fc00::/7)?
# GIT_BLOCK_PRIVATE="false"
# Strengster Modus: nur diese Hosts sind erlaubt, alles andere wird geblockt.
# Eine Angabe hier hebt die beiden Schalter darueber auf.
# GIT_ALLOWED_HOSTS="github.com,gitlab.com,git.meine-firma.de"
+72
View File
@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────
# Nebula – Produktions-Image (Next.js standalone + Prisma)
# Build: docker compose --profile full build
# Start: docker compose --profile full up -d
# ─────────────────────────────────────────────────────────────
FROM node:22-alpine AS base
# openssl wird von den Prisma-Engines benoetigt, libc6-compat von Next.js.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# ── Abhaengigkeiten ──────────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json* ./
# prisma/ wird schon hier gebraucht, weil "postinstall" prisma generate ausfuehrt.
COPY prisma ./prisma
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
# ── Build ────────────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Zur Build-Zeit wird keine echte Datenbank kontaktiert (alle Seiten sind
# dynamisch), Prisma verlangt die Variable aber beim Generieren.
ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build?schema=public"
RUN npm run build
# ── Laufzeit ─────────────────────────────────────────────────
FROM base AS runner
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
# Prisma-CLI fuer "migrate deploy" beim Containerstart. Sie bekommt eine
# eigene, minimale Installation unter /opt: aus dem Builder laesst sie sich
# nicht herausloesen, weil @prisma/config einen ganzen Abhaengigkeitsbaum
# nachzieht (effect, c12, …). Die Version wird aus dem Builder uebernommen,
# damit CLI und generierter Client nicht auseinanderlaufen.
COPY --from=builder /app/node_modules/prisma/package.json /tmp/prisma-cli.json
RUN npm install --prefix /opt/prisma-cli --no-save --no-audit --no-fund \
"prisma@$(node -p "require('/tmp/prisma-cli.json').version")" \
&& rm -f /tmp/prisma-cli.json \
&& npm cache clean --force
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Query-Engine und Schema fuer die Laufzeit bzw. die Migration.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "server.js"]
+283
View File
@@ -0,0 +1,283 @@
# Funktionsübersicht
Vollständige Auflistung dessen, was Nebula kann — gegliedert nach Bereichen.
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
- **Idee genügt.** Ein Projekt braucht nur einen Namen. Alles Weitere — Beschreibung,
Repository, Tags — lässt sich jederzeit nachtragen. Neue Projekte starten deshalb
standardmäßig im Status *Idee*.
- **Bearbeiten** über einen Dialog, der auf dem Dashboard und der Projektseite
identisch funktioniert.
- **Löschen** mit Rückfrage; zugehörige Notizen und der Repo-Snapshot verschwinden mit
(Fremdschlüssel mit `ON DELETE CASCADE`).
- **Slug** wird automatisch aus dem Namen erzeugt (umlautfest) und bei Namenskollision
durchnummeriert.
### 1.2 Felder
| Feld | Beschreibung |
| --- | --- |
| Name | Pflichtfeld |
| Kurzbeschreibung | Ein Satz für die Karte im Board |
| Beschreibung | Ausführliche Ideenskizze, Zeilenumbrüche bleiben erhalten |
| Status | Idee · In Planung · Offen · In Entwicklung · Fertig · Archiviert |
| Priorität | Niedrig · Normal · Hoch · Kritisch |
| Fortschritt | 0–100 %, per Regler |
| Akzentfarbe | Sechs Farbverläufe, prägen Karte und Detailseite |
| Tags | Kommagetrennt, bis zu 12 Stück, kleingeschrieben und dedupliziert |
| Favorit | Anpinnen — Favoriten stehen im Board immer oben |
| Git-Verbindung | Optionale Zuordnung zu einem Git-System |
| Repository | URL sowie Owner/Name (werden aus der URL abgeleitet) |
### 1.3 Status
Sechs Zustände, jeder mit eigener Farbe und Erklärung im Auswahlmenü:
| Status | Bedeutung |
| --- | --- |
| **Idee** | Noch ein Gedanke — nichts gebaut, nichts entschieden |
| **In Planung** | Scope, Stack und Schnitt werden festgelegt |
| **Offen** | Startklar, liegt aber gerade still |
| **In Entwicklung** | Wird aktiv gebaut |
| **Fertig** | Ausgeliefert und abgeschlossen |
| **Archiviert** | Ruht — bleibt erhalten, ist im Board aber ausgeblendet |
Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailbereich zu
öffnen. Die Änderung wird optimistisch angezeigt und bei einem Fehler zurückgerollt.
---
## 2. Dashboard
- **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive
Entwicklungen, fertige Projekte, verknüpfte Repositories.
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben.
- **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.
### Projektkarte auf einen Blick
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
Default-Branch samt Anzahl weiterer Branches · letzter Tag · Alter des letzten Commits ·
Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität.
---
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
- **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.
- Zeilenumbrüche und Einrückungen bleiben erhalten — Checklisten und Code-Schnipsel
überleben das Speichern.
- Eine neue Notiz aktualisiert die Änderungszeit des Projekts, damit die Sortierung
„zuletzt geändert" die tatsächliche Aktivität abbildet.
---
## 4. Git-Anbindung
### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit |
| --- | --- | --- | --- |
| **GitHub** | REST v3 | `Authorization: Bearer` | github.com und GitHub Enterprise (`/api/v3`) |
| **GitLab** | API v4 | `PRIVATE-TOKEN` | gitlab.com und Self-Hosted, verschachtelte Gruppen |
| **Gitea / Forgejo** | API v1 | `Authorization: token` | beliebige eigene Instanz |
Mehrere Verbindungen desselben Typs sind möglich — etwa privates GitHub und
Firmen-Gitea nebeneinander.
### 4.2 Verbindungen verwalten
- Anlegen mit Anzeigename, System, Basis-URL und Access Token.
- **Verbindungstest** auf Knopfdruck: prüft Erreichbarkeit und Token, zeigt den erkannten
Benutzer und bei Gitea zusätzlich die Serverversion. Ergebnis und Zeitpunkt werden
gespeichert und in der Karte angezeigt.
- **Token-Verwaltung**: verschlüsselt gespeichert, in der UI nur als Hinweis (`ghp_…9f3a`)
sichtbar. Beim Bearbeiten bedeutet ein leeres Feld „unverändert lassen".
- **Repository-Auswahl**: Nebula listet alle Repos, auf die der Token Zugriff hat, mit
Suchfeld. Ein Klick übernimmt URL, Owner, Name und — falls noch leer — Projektname und
Kurzbeschreibung.
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
### 4.3 Was beim Sync geholt wird
| Bereich | Details |
| --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
| 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, mit Kurz-SHA, Nachricht, Autor, Datum und Link |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten |
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
- **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.
- **Fehler landen am Projekt** statt in einer Fehlerseite: die Karte zeigt „Sync-Fehler",
die Projektseite die genaue Meldung. Typische HTTP-Codes werden in Klartext übersetzt
(401 Token, 403 Scopes/Rate-Limit, 404 Pfad, 408 Timeout).
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
---
## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
Raster, Neon-Akzente pro Projektfarbe.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer
Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
---
## 6. Daten und Technik
### 6.1 Datenmodell
| 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 |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) |
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus
der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
übernommen. Slug und Verbindungsname sind je Besitzer eindeutig — zwei Personen dürfen
beide ein Projekt „dashboard" haben.
Listen wie Branches oder Commits liegen als `JSONB`, damit providerspezifische Felder
nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
### 6.2 Sicherheit
- **Zugangsschutz in zwei Ebenen.** Die Middleware prüft nur, ob ein Sitzungscookie
überhaupt mitkommt — im Edge-Runtime gibt es keine Datenbank. Verbindlich entschieden
wird serverseitig in jeder Seite und jedem Endpunkt (`requireUserPage` /
`requireUserApi`). Das ist die eigentliche Sicherheitsgrenze.
- **Datentrennung** über eine Besitzerbedingung in jeder Abfrage. Schreibzugriffe laufen
über `updateMany`/`deleteMany` mit `ownerId` in der `where`-Klausel, treffen fremde
Datensätze also gar nicht erst. Fremde IDs liefern `404` statt `403`.
- **CSRF**: Origin- bzw. `Sec-Fetch-Site`-Prüfung für alle schreibenden Methoden, dazu
erzwungener `Content-Type: application/json`.
- **CSP mit Per-Request-Nonce** und `strict-dynamic`, plus `frame-ancestors 'none'`,
`object-src 'none'`, `base-uri 'none'` und die üblichen Schutz-Header.
- **SSRF-Schutz** vor jedem ausgehenden Git-Aufruf: Hostname wird aufgelöst, jede
Antwortadresse geprüft (schützt auch gegen DNS-Rebinding über einen zweiten A-Record),
Redirect-Ziele werden erneut geprüft. Konfigurierbar über `GIT_ALLOW_LOOPBACK`,
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
WebAuthn-Optionen.
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
ohne `tokenCipher`.
- **Zod-Validierung** jeder Eingabe an der API-Grenze mit Feldfehlern für die UI, Body auf
256 KB begrenzt.
- Prisma arbeitet ausschließlich mit parametrisierten Abfragen.
### 6.3 Betrieb
- **Zwei gleichwertige Betriebsarten**: Standalone (Node-Prozess, beliebige PostgreSQL)
oder komplett in Docker — dieselbe Codebasis, dieselbe `.env`.
- **Docker-Compose-Profile**: `db` läuft immer, `--profile full` startet zusätzlich die
App, `--profile tools` bringt Adminer als DB-Oberfläche mit.
- **Automatische Migration** beim Containerstart, inklusive Warteschleife auf die
Datenbank.
- **Healthchecks**: `pg_isready` für Postgres, `/api/health` für die App.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 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
[README](README.md#api).
---
## 7. Ideen für später
Bewusst nicht umgesetzt, aber vorgemerkt:
- Kanban-Board mit Drag & Drop zwischen den Statusspalten
- Webhooks, damit Pushes den Snapshot ohne Knopfdruck aktualisieren
- Markdown-Rendering in Beschreibung und Notizen
- Volltextsuche über Notizen hinweg
- Export eines Projekts als Markdown
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
- Passwort-Zurücksetzen per E-Mail (aktuell setzt ein Administrator das Passwort neu)
- Zwei-Faktor per TOTP als Alternative zum Passkey
- Geteilte Projekte zwischen Konten (heute ist die Trennung absichtlich hart)
+346
View File
@@ -0,0 +1,346 @@
<div align="center">
# ✦ Nebula
**Das Kontrollzentrum für deine Vibe-Coding Projekte.**
Ideen festhalten · Status pflegen · Notizen sammeln · Git-Repos live im Blick
<sub>Next.js 15 · TypeScript · Tailwind CSS 4 · Prisma · PostgreSQL · Docker</sub>
</div>
---
## Was ist Nebula?
Vibe Coding produziert schnell viele halbfertige Projekte: ein Repo hier, eine Idee dort,
irgendwo eine Notiz in einer Textdatei. Nebula bündelt das an einem Ort.
Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beliebig viele
**Notizen** und optional ein **verknüpftes Repository**. Nebula holt sich von dort per API
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)**.
---
## Schnellstart
Zwei Betriebsarten, dieselbe Codebasis. Beide brauchen zuerst eine `.env`:
```bash
node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRET
```
> Ohne Node zur Hand? Einfach `.env.example` nach `.env` kopieren und `APP_SECRET` von Hand
> auf einen langen Zufallswert setzen.
### Variante A — Standalone (empfohlen zum Entwickeln)
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
```bash
docker compose up -d db # Postgres 17 auf localhost:5432
npm install
npm run db:migrate # Schema anlegen
npm run db:seed # optional: drei Beispielprojekte
npm run dev # → http://localhost:3000
```
Für den Produktivbetrieb ohne Docker:
```bash
npm run build
npm start # → http://localhost:3000
```
### Variante B — Alles in Docker
```bash
docker compose --profile full up -d --build # → http://localhost:3000
```
Der App-Container spielt beim Start automatisch die Migrationen ein und wartet dabei auf
die Datenbank. Logs mitlesen:
```bash
docker compose --profile full logs -f app
```
Stoppen:
```bash
docker compose --profile full down # Container weg, Daten bleiben
docker compose --profile full down -v # inkl. Datenbank-Volume
```
### Variante C — Eigene, bereits laufende PostgreSQL
Docker wird gar nicht gebraucht. In der `.env` nur den Connection-String setzen:
```env
DATABASE_URL="postgresql://benutzer:passwort@dbhost:5432/nebula?schema=public"
```
Dann `npm install && npm run db:migrate && npm run dev`.
---
## Voraussetzungen
| Variante | Benötigt |
| --- | --- |
| Standalone | Node.js ≥ 20.11, npm, PostgreSQL ≥ 14 (lokal oder via Docker) |
| Docker | Docker Engine ≥ 24 mit Compose v2 |
---
## Konfiguration
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.
---
## 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:
| System | Scopes |
| --- | --- |
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
| GitLab | `read_api` (alternativ `read_repository`) |
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
4. Beim Anlegen oder Bearbeiten eines Projekts die Verbindung auswählen und über
**Repos laden** das Repository aussuchen (oder die URL direkt eintippen).
5. Auf der Projektseite **Jetzt synchronisieren** — Branches, Tags, Commits und Releases
werden geholt und als Snapshot gespeichert.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
---
## Projektstruktur
```
nebula/
├── docker/entrypoint.sh Migrationen + Serverstart im Container
├── docker-compose.yml db (immer) · app (--profile full) · adminer (--profile tools)
├── Dockerfile Multi-Stage-Build auf Basis des Next-Standalone-Outputs
├── prisma/
│ ├── schema.prisma Datenmodell
│ ├── migrations/ versionierte SQL-Migrationen
│ └── seed.ts Beispieldaten
├── scripts/setup.mjs .env erzeugen inkl. APP_SECRET
└── src/
├── app/
│ ├── page.tsx Dashboard (Board, Filter, Kennzahlen)
│ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Git
│ ├── connections/ Verwaltung der Git-Verbindungen
│ └── api/ REST-Endpunkte (Projekte, Notizen, Verbindungen, Sync)
├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge)
├── lib/
│ ├── crypto.ts AES-256-GCM für Access Tokens
│ ├── db.ts Prisma-Client
│ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts
│ ├── sync.ts Repo-Snapshot holen und speichern
│ └── validation.ts Zod-Schemas für alle Eingaben
└── types/ gemeinsame Typen für Server und Client
```
---
## npm-Skripte
| Skript | Zweck |
| --- | --- |
| `npm run dev` | Entwicklungsserver mit Hot Reload |
| `npm run build` / `npm start` | Produktionsbuild und -start (ohne Docker) |
| `npm run setup` | `.env` anlegen inkl. generiertem `APP_SECRET` |
| `npm run db:migrate` | Migrationen einspielen (`prisma migrate deploy`) |
| `npm run db:migrate:dev` | Neue Migration aus Schemaänderungen erzeugen |
| `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
| `npm run typecheck` | TypeScript prüfen |
| `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` |
---
## API
Alle Endpunkte liefern und erwarten JSON.
Bis auf `/api/health` und `/api/auth/*` erfordern alle Endpunkte eine Anmeldung und liefern
ausschließlich Daten des angemeldeten Kontos.
| Methode | Pfad | Zweck |
| --- | --- | --- |
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
| `PATCH` | `/api/account/profile` | Eigenes Profil |
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart und Registrierung (Admin) |
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen |
| `PATCH` `DELETE` | `/api/connections/:id` | Verbindung ändern / löschen |
| `POST` | `/api/connections/:id/check` | Verbindung und Token testen |
| `GET` | `/api/connections/:id/repos` | Zugängliche Repositories auflisten |
Beispiel — Idee per Kommandozeile anlegen:
```bash
curl -X POST http://localhost:3000/api/projects \
-H "Content-Type: application/json" \
-d '{"name":"Neue Idee","status":"IDEA","tags":["experiment"]}'
```
---
## 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:
- 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.
---
## Fehlersuche
| Symptom | Ursache / Lösung |
| --- | --- |
| `Can't reach database server` | Läuft `docker compose up -d db`? Stimmt `DATABASE_URL`? |
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
| 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 |
---
## Lizenz
MIT — siehe [LICENSE](LICENSE).
+87
View File
@@ -0,0 +1,87 @@
name: nebula
# ─────────────────────────────────────────────────────────────
# Zwei Betriebsarten aus einer Datei:
#
# 1) Standalone-Entwicklung – nur die Datenbank im Container:
# docker compose up -d db
# npm run dev
#
# 2) Alles im Container:
# docker compose --profile full up -d --build
#
# Optionales DB-Webinterface:
# docker compose --profile tools up -d adminer → http://localhost:8080
# ─────────────────────────────────────────────────────────────
services:
db:
image: postgres:17-alpine
container_name: nebula-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-nebula}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nebula}
POSTGRES_DB: ${POSTGRES_DB:-nebula}
# Deterministische Sortierung unabhaengig vom Host-Locale
POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- nebula-db-data:/var/lib/postgresql/data
healthcheck:
test:
["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nebula} -d ${POSTGRES_DB:-nebula}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
app:
profiles: ["full"]
build:
context: .
dockerfile: Dockerfile
image: nebula-app:latest
container_name: nebula-app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
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
ports:
- "${APP_PORT:-3000}:3000"
adminer:
profiles: ["tools"]
image: adminer:latest
container_name: nebula-adminer
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-8080}:8080"
volumes:
nebula-db-data:
name: nebula-db-data
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Startet Nebula im Container: erst Migrationen, dann der Server.
# ─────────────────────────────────────────────────────────────
set -e
PRISMA_CLI="/opt/prisma-cli/node_modules/prisma/build/index.js"
SCHEMA="/app/prisma/schema.prisma"
if [ -z "$DATABASE_URL" ]; then
echo "[nebula] FEHLER: DATABASE_URL ist nicht gesetzt." >&2
exit 1
fi
if [ ! -f "$PRISMA_CLI" ]; then
echo "[nebula] FEHLER: Prisma-CLI nicht unter $PRISMA_CLI gefunden." >&2
exit 1
fi
echo "[nebula] Migrationen werden eingespielt…"
attempt=0
max_attempts=15
until node "$PRISMA_CLI" migrate deploy --schema "$SCHEMA"; do
attempt=$((attempt + 1))
if [ "$attempt" -ge "$max_attempts" ]; then
echo "[nebula] FEHLER: Migration nach $max_attempts Versuchen fehlgeschlagen." >&2
exit 1
fi
echo "[nebula] Datenbank noch nicht bereit – neuer Versuch in 3s ($attempt/$max_attempts)"
sleep 3
done
echo "[nebula] Migrationen ok. Server startet auf Port ${PORT:-3000}."
exec "$@"
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-5
View File
@@ -7,12 +7,7 @@ const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
poweredByHeader: false,
experimental: {
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
serverActions: {
bodySizeLimit: "2mb",
},
},
serverExternalPackages: ["@prisma/client", "prisma"],
};
+2956
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -30,6 +30,8 @@
},
"dependencies": {
"@prisma/client": "^6.8.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"react": "^19.1.0",
@@ -0,0 +1,132 @@
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('IDEA', 'OPEN', 'PLANNING', 'IN_DEVELOPMENT', 'DONE', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "GitProvider" AS ENUM ('GITHUB', 'GITLAB', 'GITEA');
-- CreateTable
CREATE TABLE "GitConnection" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" "GitProvider" NOT NULL,
"baseUrl" TEXT NOT NULL,
"username" TEXT,
"tokenCipher" TEXT,
"tokenHint" TEXT,
"lastCheckedAt" TIMESTAMP(3),
"lastCheckOk" BOOLEAN,
"lastCheckMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GitConnection_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"summary" TEXT,
"description" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"progress" INTEGER NOT NULL DEFAULT 0,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"favorite" BOOLEAN NOT NULL DEFAULT false,
"repoUrl" TEXT,
"repoOwner" TEXT,
"repoName" TEXT,
"defaultBranch" TEXT,
"connectionId" TEXT,
"lastSyncedAt" TIMESTAMP(3),
"syncError" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Note" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT,
"content" TEXT NOT NULL,
"pinned" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Note_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RepoSnapshot" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"fullName" TEXT,
"description" TEXT,
"defaultBranch" TEXT,
"htmlUrl" TEXT,
"visibility" TEXT,
"stars" INTEGER NOT NULL DEFAULT 0,
"forks" INTEGER NOT NULL DEFAULT 0,
"openIssues" INTEGER NOT NULL DEFAULT 0,
"openPullRequests" INTEGER NOT NULL DEFAULT 0,
"sizeKb" INTEGER NOT NULL DEFAULT 0,
"lastCommitSha" TEXT,
"lastCommitMessage" TEXT,
"lastCommitAuthor" TEXT,
"lastCommitAt" TIMESTAMP(3),
"latestTag" TEXT,
"latestRelease" TEXT,
"latestReleaseAt" TIMESTAMP(3),
"branchCount" INTEGER NOT NULL DEFAULT 0,
"tagCount" INTEGER NOT NULL DEFAULT 0,
"branches" JSONB NOT NULL DEFAULT '[]',
"tags" JSONB NOT NULL DEFAULT '[]',
"commits" JSONB NOT NULL DEFAULT '[]',
"releases" JSONB NOT NULL DEFAULT '[]',
"languages" JSONB NOT NULL DEFAULT '{}',
"pushedAt" TIMESTAMP(3),
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RepoSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "GitConnection_name_key" ON "GitConnection"("name");
-- CreateIndex
CREATE INDEX "GitConnection_provider_idx" ON "GitConnection"("provider");
-- CreateIndex
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
-- CreateIndex
CREATE INDEX "Project_status_idx" ON "Project"("status");
-- CreateIndex
CREATE INDEX "Project_connectionId_idx" ON "Project"("connectionId");
-- CreateIndex
CREATE INDEX "Project_updatedAt_idx" ON "Project"("updatedAt");
-- CreateIndex
CREATE INDEX "Note_projectId_idx" ON "Note"("projectId");
-- CreateIndex
CREATE INDEX "Note_pinned_idx" ON "Note"("pinned");
-- CreateIndex
CREATE UNIQUE INDEX "RepoSnapshot_projectId_key" ON "RepoSnapshot"("projectId");
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Note" ADD CONSTRAINT "Note_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
-- CreateEnum
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
-- DropIndex
DROP INDEX "GitConnection_name_key";
-- DropIndex
DROP INDEX "Project_slug_key";
-- AlterTable
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"displayName" TEXT,
"email" TEXT,
"passwordHash" TEXT,
"role" "UserRole" NOT NULL DEFAULT 'USER',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userAgent" TEXT,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Credential" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"credentialId" TEXT NOT NULL,
"publicKey" BYTEA NOT NULL,
"counter" INTEGER NOT NULL DEFAULT 0,
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
"deviceType" TEXT,
"backedUp" BOOLEAN NOT NULL DEFAULT false,
"name" TEXT,
"lastUsedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebAuthnChallenge" (
"id" TEXT NOT NULL,
"challenge" TEXT NOT NULL,
"userId" TEXT,
"purpose" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AppSettings" (
"id" TEXT NOT NULL DEFAULT 'singleton',
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
-- CreateIndex
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
-- CreateIndex
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
-- CreateIndex
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
-- CreateIndex
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+285
View File
@@ -0,0 +1,285 @@
// ─────────────────────────────────────────────────────────────
// Nebula – Datenmodell
// Datenbank: PostgreSQL (lokal oder via docker compose)
// ─────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
// linux-musl-openssl-3.0.x wird fuer das Alpine-Docker-Image benoetigt.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Lebenszyklus eines Projekts. IDEA ist der Startzustand fuer
/// frisch notierte Einfaelle, ARCHIVED blendet Projekte aus dem
/// Standard-Board aus, ohne sie zu loeschen.
enum ProjectStatus {
IDEA
OPEN
PLANNING
IN_DEVELOPMENT
DONE
ARCHIVED
}
/// Unterstuetzte Git-Systeme.
enum GitProvider {
GITHUB
GITLAB
GITEA
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole {
ADMIN
USER
}
/// SINGLE_USER: genau ein Konto, keine Registrierung, keine Benutzerliste.
/// MULTI_USER: mehrere Konten, jedes mit streng getrennten Daten.
enum AppMode {
SINGLE_USER
MULTI_USER
}
/// Ein Benutzerkonto. Das Passwort ist optional, weil ein Konto auch
/// ausschliesslich per Passkey nutzbar sein darf.
model User {
id String @id @default(cuid())
/// Immer kleingeschrieben gespeichert, damit die Anmeldung
/// gross-/kleinschreibungsunabhaengig funktioniert.
username String @unique
displayName String?
email String? @unique
/// scrypt-Hash im Format scrypt$N$r$p$<salt>$<hash>; null = nur Passkey.
passwordHash String?
role UserRole @default(USER)
isActive Boolean @default(true)
lastLoginAt DateTime?
/// Zaehler und Sperre gegen Passwortraten.
failedLoginCount Int @default(0)
lockedUntil DateTime?
sessions Session[]
credentials Credential[]
projects Project[]
connections GitConnection[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([role])
}
/// Serverseitige Sitzung. In der Datenbank liegt nur der SHA-256-Hash des
/// Cookie-Tokens, damit ein Datenbankleck keine uebernehmbaren Sitzungen ergibt.
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
expiresAt DateTime
lastSeenAt DateTime @default(now())
userAgent String?
ip String?
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Ein registrierter Passkey (WebAuthn-Credential).
model Credential {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// base64url-kodierte Credential-ID des Authenticators.
credentialId String @unique
publicKey Bytes
counter Int @default(0)
transports String[] @default([])
deviceType String?
backedUp Boolean @default(false)
/// Frei waehlbarer Name, z. B. "MacBook Touch ID".
name String?
lastUsedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge {
id String @id @default(cuid())
challenge String @unique
/// null bei der Anmeldung ueber auffindbare Passkeys.
userId String?
/// "registration" oder "authentication"
purpose String
expiresAt DateTime
createdAt DateTime @default(now())
@@index([expiresAt])
}
/// Einstellungen der Instanz. Genau eine Zeile mit der id "singleton".
model AppSettings {
id String @id @default("singleton")
mode AppMode @default(SINGLE_USER)
/// Nur im Mehrbenutzerbetrieb wirksam.
allowRegistration Boolean @default(false)
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
rpName String @default("Nebula")
updatedAt DateTime @updatedAt
}
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
model GitConnection {
id String @id @default(cuid())
name String
provider GitProvider
/// Basis-URL der Instanz, z. B. https://github.com, https://gitlab.com,
/// https://git.meine-domain.de
baseUrl String
/// Optionaler Benutzername – rein informativ fuer die Anzeige.
username String?
/// Personal Access Token, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
tokenCipher String?
/// Gekuerzte Darstellung des Tokens fuer die UI, z. B. "ghp_…4f2a".
tokenHint String?
lastCheckedAt DateTime?
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])
@@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
/// Einzeiler fuer die Karte im Dashboard.
summary String?
/// Ausfuehrliche Beschreibung / Ideenskizze (Markdown-freundlich).
description String?
status ProjectStatus @default(IDEA)
/// 1 = niedrig, 2 = normal, 3 = hoch, 4 = kritisch
priority Int @default(2)
/// Fortschritt in Prozent (0–100)
progress Int @default(0)
/// Akzentfarbe der Karte: violet | cyan | emerald | amber | rose | blue
accent String @default("violet")
tags String[] @default([])
favorite Boolean @default(false)
// ── Git-Anbindung ──────────────────────────────────────────
repoUrl String?
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
repoOwner String?
repoName String?
defaultBranch String?
connectionId String?
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
lastSyncedAt DateTime?
syncError String?
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
notes Note[]
snapshot RepoSnapshot?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([connectionId])
@@index([updatedAt])
@@index([ownerId])
}
/// Freie Notizen zu einem Projekt.
model Note {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String?
content String
pinned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId])
@@index([pinned])
}
/// Zwischengespeicherter Stand des verknuepften Repositories. Wird beim
/// Sync ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
model RepoSnapshot {
id String @id @default(cuid())
projectId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
fullName String?
description String?
defaultBranch String?
htmlUrl String?
visibility String?
stars Int @default(0)
forks Int @default(0)
openIssues Int @default(0)
openPullRequests Int @default(0)
sizeKb Int @default(0)
lastCommitSha String?
lastCommitMessage String?
lastCommitAuthor String?
lastCommitAt DateTime?
latestTag String?
latestRelease String?
latestReleaseAt DateTime?
branchCount Int @default(0)
tagCount Int @default(0)
/// 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("{}")
pushedAt DateTime?
fetchedAt DateTime @default(now())
}
+121
View File
@@ -0,0 +1,121 @@
import { PrismaClient, ProjectStatus } from "@prisma/client";
const prisma = new PrismaClient();
interface SampleNote {
title?: string | null;
content: string;
pinned?: boolean;
}
interface SampleProject {
name: string;
slug: string;
summary: string;
description: string;
status: ProjectStatus;
priority: number;
progress: number;
accent: string;
tags: string[];
favorite: boolean;
notes: SampleNote[];
}
/**
* Legt ein paar Beispielprojekte an, damit das Dashboard nach dem ersten
* Start nicht leer ist. Idempotent: bereits vorhandene Slugs werden
* uebersprungen.
*/
async function main() {
const samples: SampleProject[] = [
{
name: "Nebula",
slug: "nebula",
summary: "Dieses Dashboard – Uebersicht ueber alle Vibe-Coding Projekte.",
description:
"Meta: Nebula verwaltet sich selbst. Notizen, Status und Git-Infos an einem Ort.",
status: ProjectStatus.IN_DEVELOPMENT,
priority: 3,
progress: 65,
accent: "violet",
tags: ["next.js", "prisma", "dashboard"],
favorite: true,
notes: [
{
title: "Naechste Schritte",
content:
"- Kanban-Ansicht ergaenzen\n- Webhook-Endpoint fuer Push-Events\n- Export als Markdown",
pinned: true,
},
],
},
{
name: "Synthwave CLI",
slug: "synthwave-cli",
summary: "Terminal-Tool, das Projektgeruest inkl. CI in Sekunden erzeugt.",
description:
"Idee: ein `npx synthwave init` das Repo, Lint, Tests, Container und Pipeline vorkonfiguriert.",
status: ProjectStatus.PLANNING,
priority: 2,
progress: 15,
accent: "cyan",
tags: ["cli", "typescript", "dx"],
favorite: false,
notes: [
{
title: null,
content: "Konkurrenz pruefen: create-t3-app, degit, plop.",
pinned: false,
},
],
},
{
name: "Pixelgarten",
slug: "pixelgarten",
summary: "Browser-Spiel: ein Garten, der aus Commit-Aktivitaet waechst.",
description:
"Rohe Idee. Jeder Commit pflanzt eine Pflanze, Streaks lassen sie bluehen.",
status: ProjectStatus.IDEA,
priority: 1,
progress: 0,
accent: "emerald",
tags: ["spiel", "canvas", "fun"],
favorite: false,
notes: [],
},
];
// 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 },
});
if (existing) {
console.log(` · uebersprungen (existiert): ${sample.slug}`);
continue;
}
const { notes, ...project } = sample;
await prisma.project.create({
data: {
...project,
notes: notes.length ? { create: notes } : undefined,
},
});
console.log(` ✓ angelegt: ${sample.slug}`);
}
}
main()
.then(async () => {
console.log("Seed abgeschlossen.");
await prisma.$disconnect();
})
.catch(async (error) => {
console.error("Seed fehlgeschlagen:", error);
await prisma.$disconnect();
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Nebula – Ersteinrichtung.
*
* Legt eine .env auf Basis von .env.example an und erzeugt dabei einen
* zufaelligen APP_SECRET. Vorhandene .env-Dateien werden nicht angefasst.
*
* node scripts/setup.mjs bzw. npm run setup
*/
import { randomBytes } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const envPath = join(root, ".env");
const examplePath = join(root, ".env.example");
const c = {
reset: "",
dim: "",
green: "",
yellow: "",
cyan: "",
};
function log(message) {
console.log(message);
}
if (!existsSync(examplePath)) {
console.error(`${c.yellow}.env.example nicht gefunden – abgebrochen.${c.reset}`);
process.exit(1);
}
if (existsSync(envPath)) {
log(`${c.yellow}·${c.reset} .env existiert bereits – sie wird nicht ueberschrieben.`);
} else {
const secret = randomBytes(32).toString("base64");
const content = readFileSync(examplePath, "utf8").replace(
/^APP_SECRET=.*$/m,
`APP_SECRET="${secret}"`,
);
writeFileSync(envPath, content, "utf8");
log(`${c.green}✓${c.reset} .env angelegt (APP_SECRET wurde zufaellig erzeugt).`);
}
log("");
log(`${c.cyan}Naechste Schritte${c.reset}`);
log(`${c.dim}─────────────────────────────────────────────${c.reset}`);
log("Standalone (App lokal, Datenbank im Container):");
log(` ${c.dim}1.${c.reset} docker compose up -d db`);
log(` ${c.dim}2.${c.reset} npm install`);
log(` ${c.dim}3.${c.reset} npm run db:migrate`);
log(` ${c.dim}4.${c.reset} npm run db:seed ${c.dim}(optional, Beispieldaten)${c.reset}`);
log(` ${c.dim}5.${c.reset} npm run dev ${c.dim}→ http://localhost:3000${c.reset}`);
log("");
log("Alles in Docker:");
log(` ${c.dim}1.${c.reset} docker compose --profile full up -d --build`);
log(` ${c.dim}2.${c.reset} ${c.dim}→ http://localhost:3000${c.reset}`);
log("");
log("Eigene, bereits laufende PostgreSQL:");
log(` DATABASE_URL in der .env anpassen, dann npm run db:migrate`);
log("");
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
import { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Mein Konto" };
}
export default async function AccountPage() {
const user = await requireUserPage();
const [passkeys, sessions] = await Promise.all([
prisma.credential.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
deviceType: true,
backedUp: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "asc" },
}),
prisma.session.findMany({
where: { userId: user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
},
orderBy: { lastSeenAt: "desc" },
}),
]);
const data: AccountData = {
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
hasPassword: user.hasPassword,
passkeys,
sessions,
passkeysAvailable: WEBAUTHN_AVAILABLE,
};
return (
<div className="mx-auto max-w-3xl space-y-6 pt-4">
<header className="fade-up">
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
<p className="mt-1 text-sm text-slate-400">
Profil, Anmeldemittel und aktive Sitzungen.
</p>
</header>
<AccountManager data={data} />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { AdminManager, type AdminData } from "@/components/admin/AdminManager";
import { requireAdminPage } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Admin-Einstellungen" };
}
export default async function AdminPage() {
const admin = await requireAdminPage();
const [settings, users] = await Promise.all([
getAppSettings(),
prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
}),
]);
const data: AdminData = {
mode: settings.mode,
allowRegistration: settings.allowRegistration,
users,
currentUserId: admin.id,
appUrl: APP_URL,
passkeysAvailable: WEBAUTHN_AVAILABLE,
};
return (
<div className="mx-auto max-w-4xl space-y-6 pt-4">
<header className="fade-up">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Administration
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
Admin-Einstellungen
</h1>
<p className="mt-1 text-sm text-slate-400">
Betriebsart, Registrierung und Benutzerkonten.
</p>
</header>
<AdminManager data={data} />
</div>
);
}
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Umbenennen – nur der eigene Passkey. */
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = passkeyNameSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
// gar nicht erst getroffen werden.
const result = await prisma.credential.updateMany({
where: { id, userId: auth.user.id },
data: { name: parsed.data.name ?? null },
});
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id: auth.user.id },
select: { passwordHash: true, _count: { select: { credentials: true } } },
});
if (!user) return jsonError("Konto nicht gefunden.", 404);
// Nicht das letzte Anmeldemittel entfernen.
if (!user.passwordHash && user._count.credentials <= 1) {
return jsonError(
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
"Bitte zuerst ein Passwort vergeben.",
409,
);
}
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
return NextResponse.json({ ok: true });
}
+71
View File
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { changePasswordSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Eigenes Passwort setzen oder aendern.
*
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = changePasswordSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
if (!user) return jsonError("Konto nicht gefunden.", 404);
if (user.passwordHash) {
const current = parsed.data.currentPassword ?? "";
const valid = await verifyPassword(current, user.passwordHash);
if (!valid) {
return NextResponse.json(
{
error: "Das aktuelle Passwort stimmt nicht.",
fields: { currentPassword: "Falsch" },
},
{ status: 401 },
);
}
}
const weak = validatePasswordStrength(parsed.data.newPassword);
if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
}
await prisma.user.update({
where: { id: user.id },
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
});
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
// ausstellen, damit der Wechsel nicht abmeldet.
await destroyAllSessions(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = profileUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { displayName, email } = parsed.data;
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id: auth.user.id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.update({
where: { id: auth.user.id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
},
select: { id: true, username: true, displayName: true, email: true, role: true },
});
return NextResponse.json({ user });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { clientIp } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Uebersicht der eigenen aktiven Sitzungen. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const sessions = await prisma.session.findMany({
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
select: {
id: true,
userAgent: true,
ip: true,
createdAt: true,
lastSeenAt: true,
expiresAt: true,
},
orderBy: { lastSeenAt: "desc" },
});
return NextResponse.json({ sessions });
}
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
export async function DELETE(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
await destroyAllSessions(auth.user.id);
const session = await createSession(auth.user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({ ok: true });
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
return NextResponse.json({ settings, userCount });
}
export async function PATCH(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminSettingsSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { mode, allowRegistration } = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
if (mode === "SINGLE_USER") {
const userCount = await prisma.user.count();
if (userCount > 1) {
return jsonError(
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
"Bitte zuerst die uebrigen Konten entfernen.",
409,
);
}
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
create: {
id: "singleton",
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
},
update: {
...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}),
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
},
});
return NextResponse.json({ settings });
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { destroyAllSessions } from "@/lib/auth/session";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
const target = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (target?.role !== "ADMIN") return false;
const activeAdmins = await prisma.user.count({
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
});
return activeAdmins === 0;
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return jsonError("Konto nicht gefunden.", 404);
const { displayName, email, role, isActive, password } = parsed.data;
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
return jsonError(
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
409,
);
}
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
if (password) {
const weak = validatePasswordStrength(password);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
}
const user = await prisma.user.update({
where: { id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
...(role ? { role } : {}),
...(isActive !== undefined ? { isActive } : {}),
...(password ? { passwordHash: await hashPassword(password) } : {}),
// Ein Eingriff des Administrators hebt eine Sperre auf.
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
},
select: userSafeSelect,
});
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
if (isActive === false || password) {
await destroyAllSessions(id);
}
return NextResponse.json({ user });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (id === auth.user.id) {
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
}
if (await wouldRemoveLastAdmin(id)) {
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
}
try {
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
// onDelete: Cascade daran und verschwinden mit.
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return jsonError("Konto nicht gefunden.", 404);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { getInstanceState } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const users = await prisma.user.findMany({
select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }],
});
return NextResponse.json({ users });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const state = await getInstanceState();
if (!state.multiUser) {
return jsonError(
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
409,
);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (taken) {
return NextResponse.json(
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
{ status: 409 },
);
}
if (email) {
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (emailTaken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role,
},
select: userSafeSelect,
});
return NextResponse.json({ user }, { status: 201 });
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { loginSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
const GENERIC = "Benutzername oder Passwort ist falsch.";
export async function POST(request: Request) {
const ip = clientIp(request);
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = loginSchema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const { username, password } = parsed.data;
const byUser = rateLimit(
`login:user:${username}`,
LIMITS.loginUser.limit,
LIMITS.loginUser.windowMs,
);
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
await fakeVerifyDelay();
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) {
return jsonError(
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
423,
);
}
if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
if (!user.passwordHash) {
await fakeVerifyDelay();
return jsonError(
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
401,
);
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
await noteSuccessfulLogin(user.id);
resetRateLimit(`login:user:${username}`);
resetRateLimit(`login:ip:${ip}`);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session";
export const dynamic = "force-dynamic";
export async function POST() {
await destroyCurrentSession();
const response = NextResponse.json({ ok: true });
clearSessionCookie(response);
return response;
}
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/**
* Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der
* Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht
* noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten.
*/
export async function POST(request: Request) {
const limited = rateLimit(
`webauthn:auth:${clientIp(request)}`,
LIMITS.webauthn.limit,
LIMITS.webauthn.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
try {
const options = await buildAuthenticationOptions();
return NextResponse.json({ options });
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 500);
}
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
interface Payload {
response?: AuthenticationResponseJSON;
}
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody<Payload>(request);
if (!body.ok) return body.response;
const assertion = body.data?.response;
if (!assertion || typeof assertion !== "object") {
return jsonError("Die Antwort des Authenticators fehlt.", 400);
}
try {
const result = await confirmAuthentication(assertion);
const user = await prisma.user.findUnique({ where: { id: result.userId } });
if (!user || !user.isActive) {
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
await noteSuccessfulLogin(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 401);
}
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/** Liefert die Optionen fuer navigator.credentials.create(). */
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`webauthn:reg:${clientIp(request)}`,
LIMITS.webauthn.limit,
LIMITS.webauthn.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
try {
const options = await buildRegistrationOptions({
id: auth.user.id,
username: auth.user.username,
displayName: auth.user.displayName,
});
return NextResponse.json({ options });
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 500);
}
}
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
import { requireUserApi } from "@/lib/auth/guard";
import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn";
import { jsonError, readJsonBody } from "@/lib/security/request";
export const dynamic = "force-dynamic";
interface Payload {
response?: RegistrationResponseJSON;
name?: string | null;
}
/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const body = await readJsonBody<Payload>(request);
if (!body.ok) return body.response;
const attestation = body.data?.response;
if (!attestation || typeof attestation !== "object") {
return jsonError("Die Antwort des Authenticators fehlt.", 400);
}
try {
const credential = await confirmRegistration(
auth.user.id,
attestation,
typeof body.data?.name === "string" ? body.data.name : null,
);
return NextResponse.json({
ok: true,
credential: {
id: credential.id,
name: credential.name,
deviceType: credential.deviceType,
backedUp: credential.backedUp,
createdAt: credential.createdAt,
},
});
} catch (error) {
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
return jsonError(error instanceof Error ? error.message : String(error), 400);
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, registerSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb
* laeuft und ein Administrator die Registrierung freigeschaltet hat.
*/
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const state = await getInstanceState();
if (state.needsSetup) {
return jsonError("Die Instanz ist noch nicht eingerichtet.", 409);
}
if (!state.registrationOpen) {
return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = registerSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
if (taken) {
return NextResponse.json(
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
{ status: 409 },
);
}
if (email) {
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
if (emailTaken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role: "USER",
},
});
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
export const dynamic = "force-dynamic";
/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */
export async function GET() {
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
return NextResponse.json({
user,
needsSetup: state.needsSetup,
registrationOpen: state.registrationOpen,
multiUser: state.multiUser,
passkeysAvailable: WEBAUTHN_AVAILABLE,
});
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { claimOrphanedData } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, setupSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Erstmalige Einrichtung: legt das Administratorkonto an.
*
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
* erschleichen.
*/
export async function POST(request: Request) {
const existing = await prisma.user.count();
if (existing > 0) {
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = setupSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password);
if (weak) {
return NextResponse.json(
{ error: weak, fields: { password: weak } },
{ status: 422 },
);
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role: "ADMIN",
},
});
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", mode },
update: { mode },
});
const claimed = await claimOrphanedData(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
claimed,
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}
@@ -0,0 +1,44 @@
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);
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 result = await clientForConnection(connection).check();
await prisma.gitConnection.update({
where: { id },
data: {
lastCheckedAt: new Date(),
lastCheckOk: result.ok,
lastCheckMessage: result.message,
username: result.user ?? connection.username,
},
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
@@ -0,0 +1,41 @@
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 }> };
/** 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);
try {
const repos = await clientForConnection(connection).listRepos(query);
return NextResponse.json({ repos });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 502 },
);
}
}
+112
View File
@@ -0,0 +1,112 @@
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";
type Params = { params: Promise<{ id: string }> };
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: 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 prisma.gitConnection.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Verbindung nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = connectionUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.name !== undefined) update.name = data.name;
if (data.provider !== undefined) update.provider = data.provider;
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
if (data.token !== undefined) {
if (data.token) {
update.tokenCipher = encryptSecret(data.token);
update.tokenHint = tokenHint(data.token);
} else {
update.tokenCipher = null;
update.tokenHint = null;
}
}
if (data.name !== undefined) {
const duplicate = await prisma.gitConnection.findFirst({
where: { name: data.name, ownerId: auth.user.id, NOT: { id } },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
}
const connection = await prisma.gitConnection.update({
where: { id },
data: update,
select: safeSelect,
});
return NextResponse.json({ connection });
}
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 });
}
+98
View File
@@ -0,0 +1,98 @@
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";
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: true } },
};
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" },
});
return NextResponse.json({ connections });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = connectionCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
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 },
});
if (duplicate) {
return NextResponse.json(
{
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),
username: username ?? null,
tokenCipher: token ? encryptSecret(token) : null,
tokenHint: token ? tokenHint(token) : null,
},
select: safeSelect,
});
return NextResponse.json({ connection }, { status: 201 });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
/** Liveness/Readiness-Endpoint – wird auch vom Docker-Healthcheck benutzt. */
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: "ok",
database: "up",
time: new Date().toISOString(),
});
} catch (error) {
return NextResponse.json(
{
status: "degraded",
database: "down",
error: error instanceof Error ? error.message : String(error),
},
{ status: 503 },
);
}
}
+62
View File
@@ -0,0 +1,62 @@
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;
const parsed = noteUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const updated = await prisma.note.update({
where: { id },
data: {
...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}),
...(parsed.data.content !== undefined ? { content: parsed.data.content } : {}),
...(parsed.data.pinned !== undefined ? { pinned: parsed.data.pinned } : {}),
},
});
return NextResponse.json({ note: updated });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
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 });
}
+68
View File
@@ -0,0 +1,68 @@
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" }],
});
return NextResponse.json({ notes });
}
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 = noteCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const note = await prisma.note.create({
data: {
projectId: id,
title: parsed.data.title ?? null,
content: parsed.data.content,
pinned: parsed.data.pinned,
},
});
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
return NextResponse.json({ note }, { status: 201 });
}
+118
View File
@@ -0,0 +1,118 @@
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";
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 },
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);
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;
const parsed = projectUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const existing = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
});
if (!existing) return jsonError("Projekt nicht gefunden", 404);
const data = parsed.data;
if (data.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: data.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const update: Record<string, unknown> = {};
if (data.name !== undefined && data.name !== existing.name) {
update.name = data.name;
update.slug = await uniqueSlug(data.name, auth.user.id, id);
}
for (const key of [
"summary",
"description",
"status",
"priority",
"progress",
"accent",
"tags",
"favorite",
"connectionId",
] as const) {
if (data[key] !== undefined) update[key] = data[key];
}
if (data.repoUrl !== undefined) {
update.repoUrl = data.repoUrl;
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null;
if (parsedUrl) {
update.repoOwner = data.repoOwner ?? parsedUrl.owner;
update.repoName = data.repoName ?? parsedUrl.name;
} else if (!data.repoUrl) {
update.repoOwner = null;
update.repoName = null;
}
}
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName;
const project = await prisma.project.update({
where: { id },
data: update,
include: projectListInclude,
});
return NextResponse.json({ project });
}
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 });
}
+43
View File
@@ -0,0 +1,43 @@
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";
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);
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 },
include: projectDetailInclude,
});
return NextResponse.json({ ...result, project }, { status: result.ok ? 200 : 502 });
}
+108
View File
@@ -0,0 +1,108 @@
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
? {
OR: [
{ name: { contains: query, mode: "insensitive" as const } },
{ summary: { contains: query, mode: "insensitive" as const } },
{ tags: { has: query.toLowerCase() } },
],
}
: {}),
},
include: projectListInclude,
orderBy: [{ favorite: "desc" }, { updatedAt: "desc" }],
});
return NextResponse.json({ projects });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
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;
if (data.repoUrl && (!repoOwner || !repoName)) {
const parsedUrl = parseRepoUrl(data.repoUrl);
if (parsedUrl) {
repoOwner = repoOwner ?? parsedUrl.owner;
repoName = repoName ?? parsedUrl.name;
}
}
const project = await prisma.project.create({
data: {
ownerId: auth.user.id,
name: data.name,
slug: await uniqueSlug(data.name, auth.user.id),
summary: data.summary ?? null,
description: data.description ?? null,
status: data.status,
priority: data.priority,
progress: data.progress,
accent: data.accent,
tags: data.tags,
favorite: data.favorite,
repoUrl: data.repoUrl ?? null,
repoOwner,
repoName,
connectionId: data.connectionId ?? null,
},
include: projectListInclude,
});
return NextResponse.json({ project }, { status: 201 });
}
+193
View File
@@ -0,0 +1,193 @@
/* ─────────────────────────────────────────────────────────────
Nebula – wiederkehrende Bausteine.
Bewusst in @layer components: Tailwind sortiert die Layer als
theme → base → components → utilities. Ohne diese Zuordnung wuerden
die Kurzschreibweisen hier (padding, border-radius …) die Utilities
ueberschreiben, weil sie spaeter im Stylesheet stehen – aus
"field pl-9" wuerde dann wieder das linke Standardpadding und das
Suchsymbol laege auf dem Platzhaltertext.
───────────────────────────────────────────────────────────── */
@layer components {
/* ── Glasflaechen ─────────────────────────────────────────── */
.glass {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(18px) saturate(140%);
-webkit-backdrop-filter: blur(18px) saturate(140%);
border-radius: var(--radius-card);
box-shadow:
0 1px 0 0 var(--glass-highlight) inset,
0 24px 60px -30px rgba(0, 0, 0, 0.9);
}
.glass-soft {
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.07);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 0.9rem;
}
/* Karte mit Akzent-Glow. --accent-from/--accent-to setzt die Komponente. */
.card-glow {
transition:
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.35s ease,
border-color 0.35s ease;
}
.card-glow::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.35s ease;
pointer-events: none;
}
.card-glow:hover {
transform: translateY(-4px);
box-shadow:
0 1px 0 0 var(--glass-highlight) inset,
0 30px 70px -30px rgba(0, 0, 0, 0.95),
0 0 46px -12px var(--accent-from, #8b5cf6);
}
.card-glow:hover::before {
opacity: 0.85;
}
/* ── Text & Akzente ───────────────────────────────────────── */
.text-gradient {
background: linear-gradient(120deg, #c4b5fd 0%, #67e8f9 45%, #f0abfc 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.accent-bar {
background: linear-gradient(90deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
}
.accent-dot {
box-shadow: 0 0 12px 1px currentColor;
}
/* ── Buttons ──────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
padding: 0.55rem 1rem;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
filter 0.2s ease;
cursor: pointer;
white-space: nowrap;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:focus-visible {
outline: 2px solid rgba(167, 139, 250, 0.8);
outline-offset: 2px;
}
.btn-primary {
background: linear-gradient(120deg, #7c3aed, #c026d3);
color: #fff;
box-shadow: 0 10px 30px -12px rgba(168, 85, 247, 0.9);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.12);
box-shadow: 0 14px 36px -12px rgba(168, 85, 247, 1);
}
.btn-ghost {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
color: #cbd5f5;
}
.btn-ghost:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.09);
color: #fff;
}
.btn-danger {
background: rgba(244, 63, 94, 0.12);
border-color: rgba(244, 63, 94, 0.35);
color: #fda4af;
}
.btn-danger:hover:not(:disabled) {
background: rgba(244, 63, 94, 0.22);
color: #fff;
}
/* ── Formularfelder ───────────────────────────────────────── */
.field {
width: 100%;
border-radius: 0.75rem;
background: rgba(8, 10, 24, 0.6);
border: 1px solid rgba(255, 255, 255, 0.09);
padding: 0.6rem 0.85rem;
font-size: 0.875rem;
color: #e6e8f5;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.field::placeholder {
color: rgba(148, 163, 184, 0.6);
}
.field:focus {
outline: none;
border-color: rgba(167, 139, 250, 0.6);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.18);
}
.field-label {
display: block;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(148, 163, 184, 0.85);
margin-bottom: 0.4rem;
}
select.field option {
background: #0a0c1a;
color: #e6e8f5;
}
}
+29
View File
@@ -0,0 +1,29 @@
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";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Git-Verbindungen",
description: "GitHub, GitLab und Gitea verbinden.",
};
export default async function ConnectionsPage() {
const user = await requireUserPage();
const connections = await prisma.gitConnection.findMany({
where: { ownerId: user.id },
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
orderBy: { name: "asc" },
});
return (
<div className="pt-4">
<ConnectionsManager connections={connections as unknown as ConnectionWithCount[]} />
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
const isDbError =
/database|prisma|ECONNREFUSED|P1001|does not exist/i.test(error.message ?? "");
return (
<div className="glass fade-up mx-auto mt-16 max-w-2xl px-6 py-14">
<span className="grid h-12 w-12 place-items-center rounded-2xl bg-rose-500/15 text-rose-300">
<AlertTriangle className="h-6 w-6" />
</span>
<h1 className="mt-4 text-xl font-semibold text-white">Da ist etwas schiefgelaufen</h1>
<p className="mt-2 text-sm text-slate-400">{error.message}</p>
{isDbError ? (
<div className="mt-5 rounded-xl border border-white/8 bg-black/30 p-4 text-sm text-slate-400">
<p className="font-medium text-slate-200">Sieht nach einem Datenbankproblem aus.</p>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-xs">
<li>
Laeuft PostgreSQL? <code className="text-violet-300">docker compose up -d db</code>
</li>
<li>
Stimmt <code className="text-violet-300">DATABASE_URL</code> in der{" "}
<code className="text-violet-300">.env</code>?
</li>
<li>
Migrationen eingespielt? <code className="text-violet-300">npm run db:migrate</code>
</li>
</ol>
</div>
) : null}
<button type="button" className="btn btn-primary mt-6" onClick={reset}>
<RotateCcw className="h-4 w-4" />
Erneut versuchen
</button>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
@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 {
--color-void: #05060f;
--color-void-soft: #0a0c1a;
--color-haze: #12142b;
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
Consolas, "DejaVu Sans Mono", monospace;
--radius-card: 1.25rem;
}
:root {
color-scheme: dark;
--glass-bg: rgba(255, 255, 255, 0.035);
--glass-border: rgba(255, 255, 255, 0.09);
--glass-highlight: rgba(255, 255, 255, 0.16);
}
@layer base {
* {
border-color: var(--glass-border);
}
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;
}
}
/* ── Hintergrund: langsam driftende Nebel ───────────────────── */
.aurora {
position: fixed;
inset: 0;
z-index: -2;
overflow: hidden;
pointer-events: none;
}
.aurora::after {
/* feines Rauschen, damit die Verlaeufe nicht bandieren */
content: "";
position: absolute;
inset: 0;
opacity: 0.35;
background-image: radial-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 3px 3px;
mix-blend-mode: overlay;
}
.aurora-blob {
position: absolute;
border-radius: 9999px;
filter: blur(90px);
opacity: 0.5;
will-change: transform;
}
.aurora-blob--one {
width: 46rem;
height: 46rem;
top: -18rem;
left: -12rem;
background: radial-gradient(circle at 30% 30%, #7c3aed, transparent 65%);
animation: drift-a 26s ease-in-out infinite alternate;
}
.aurora-blob--two {
width: 40rem;
height: 40rem;
top: -10rem;
right: -14rem;
background: radial-gradient(circle at 60% 40%, #0ea5e9, transparent 65%);
animation: drift-b 32s ease-in-out infinite alternate;
}
.aurora-blob--three {
width: 38rem;
height: 38rem;
bottom: -20rem;
left: 28%;
background: radial-gradient(circle at 50% 50%, #db2777, transparent 68%);
animation: drift-c 38s ease-in-out infinite alternate;
opacity: 0.35;
}
.aurora-grid {
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.028) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.028) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: radial-gradient(ellipse 100% 70% at 50% 0%, #000 40%, transparent 100%);
}
@keyframes drift-a {
from { transform: translate3d(0, 0, 0) scale(1); }
to { transform: translate3d(6rem, 4rem, 0) scale(1.12); }
}
@keyframes drift-b {
from { transform: translate3d(0, 0, 0) scale(1.05); }
to { transform: translate3d(-7rem, 5rem, 0) scale(0.95); }
}
@keyframes drift-c {
from { transform: translate3d(0, 0, 0) scale(0.95); }
to { transform: translate3d(4rem, -6rem, 0) scale(1.1); }
}
/* ── Bewegung & Effekte ─────────────────────────────────────── */
.shimmer {
background: linear-gradient(
100deg,
rgba(255, 255, 255, 0) 20%,
rgba(255, 255, 255, 0.12) 50%,
rgba(255, 255, 255, 0) 80%
);
background-size: 200% 100%;
animation: shimmer 2.2s linear infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.fade-up {
animation: fade-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.pulse-ring {
animation: pulse-ring 2.4s ease-out infinite;
}
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 currentColor; opacity: 0.7; }
70% { box-shadow: 0 0 0 8px transparent; opacity: 0; }
100% { box-shadow: 0 0 0 0 transparent; opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8b5cf6" />
<stop offset="55%" stop-color="#d946ef" />
<stop offset="100%" stop-color="#22d3ee" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#05060f" />
<circle cx="32" cy="32" r="17" fill="url(#g)" opacity="0.9" />
<circle cx="32" cy="32" r="17" fill="none" stroke="#ffffff" stroke-opacity="0.25" stroke-width="1.5" />
<circle cx="46" cy="18" r="3" fill="#ffffff" opacity="0.9" />
<circle cx="16" cy="44" r="2" fill="#ffffff" opacity="0.7" />
<circle cx="20" cy="18" r="1.5" fill="#ffffff" opacity="0.55" />
</svg>

After

Width:  |  Height:  |  Size: 758 B

+61
View File
@@ -0,0 +1,61 @@
import type { Metadata, Viewport } from "next";
import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
import "./globals.css";
/**
* generateMetadata statt eines konstanten metadata-Objekts: nur so wird
* APP_NAME zur Laufzeit gelesen. Ein konstanter Export waere beim Build
* eingefroren und liesse sich im fertigen Docker-Image nicht mehr aendern.
*/
export async function generateMetadata(): Promise<Metadata> {
return {
title: {
default: `${APP_NAME} – Vibe Coding Dashboard`,
template: `%s · ${APP_NAME}`,
},
description:
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME,
robots: { index: false, follow: false },
};
}
export const viewport: Viewport = {
themeColor: "#05060f",
width: "device-width",
initialScale: 1,
};
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const user = await getSessionUser();
return (
<html lang="de" suppressHydrationWarning>
<body className="min-h-screen antialiased">
<div className="aurora" aria-hidden="true">
<div className="aurora-blob aurora-blob--one" />
<div className="aurora-blob aurora-blob--two" />
<div className="aurora-blob aurora-blob--three" />
</div>
<div className="aurora-grid" aria-hidden="true" />
<TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} />
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
{children}
</main>
<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">v0.2.0</span>
</div>
</footer>
</body>
</html>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { LoginForm } from "@/components/auth/LoginForm";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME, WEBAUTHN_AVAILABLE } from "@/lib/config";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Anmelden" };
}
type PageProps = { searchParams: Promise<{ weiter?: string }> };
export default async function LoginPage({ searchParams }: PageProps) {
const [user, state, params] = await Promise.all([
getSessionUser(),
getInstanceState(),
searchParams,
]);
if (state.needsSetup) redirect("/setup");
if (user) redirect("/");
// Nur relative Pfade zulassen – sonst waere das ein offener Redirect.
const raw = params.weiter ?? "/";
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
return (
<AuthShell
appName={APP_NAME}
title="Willkommen zurueck"
subtitle="Melde dich an, um deine Projekte zu sehen."
>
<LoginForm
passkeysAvailable={WEBAUTHN_AVAILABLE}
registrationOpen={state.registrationOpen}
next={next}
/>
</AuthShell>
);
}
+19
View File
@@ -0,0 +1,19 @@
import Link from "next/link";
import { Compass } from "lucide-react";
export default function NotFound() {
return (
<div className="glass fade-up mx-auto mt-16 max-w-lg px-6 py-16 text-center">
<span className="mx-auto grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<Compass className="h-6 w-6" />
</span>
<h1 className="mt-4 text-2xl font-semibold text-white">Nichts gefunden</h1>
<p className="mt-2 text-sm text-slate-400">
Diese Seite existiert nicht (mehr). Vielleicht wurde das Projekt geloescht.
</p>
<Link href="/" className="btn btn-primary mt-6">
Zurueck zum Dashboard
</Link>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
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";
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" },
}),
]);
const active = projects.filter((project) => project.status !== "ARCHIVED");
const ideas = active.filter((project) => project.status === "IDEA").length;
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
const done = active.filter((project) => project.status === "DONE").length;
const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length;
const averageProgress =
active.length > 0
? Math.round(active.reduce((sum, project) => sum + project.progress, 0) / active.length)
: 0;
return (
<div className="space-y-6">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Vibe Coding Kontrollzentrum
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Deine Projekte auf{" "}
<span className="text-gradient">einen Blick</span>
</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-400">
Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den
verknuepften Repositories bei GitHub, GitLab und Gitea passiert.
</p>
</header>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
label="Projekte"
value={active.length}
hint={`Ø Fortschritt ${averageProgress}%`}
icon={CircleDot}
color="#a78bfa"
/>
<StatCard
label="Ideen"
value={ideas}
hint={STATUS_META.IDEA.description}
icon={Lightbulb}
color={STATUS_META.IDEA.color}
/>
<StatCard
label="In Entwicklung"
value={inDev}
hint="Aktiv in Arbeit"
icon={Rocket}
color={STATUS_META.IN_DEVELOPMENT.color}
/>
<StatCard
label="Fertig"
value={done}
hint="Ausgeliefert"
icon={Trophy}
color={STATUS_META.DONE.color}
/>
<StatCard
label="Repos verknuepft"
value={linked}
hint={`${connections.length} Git-Verbindung${connections.length === 1 ? "" : "en"}`}
icon={GitBranch}
color="#38bdf8"
/>
</div>
<ProjectBoard
projects={projects as unknown as ProjectListItem[]}
connections={connections as unknown as ConnectionSafe[]}
/>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { FileText } from "lucide-react";
import { GitPanel } from "@/components/GitPanel";
import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types";
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 },
select: { name: true, summary: true },
});
return {
title: project?.name ?? "Projekt",
description: project?.summary ?? undefined,
};
}
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" },
}),
]);
if (!project) notFound();
const detail = project as unknown as ProjectDetail;
return (
<div className="space-y-6 pt-4">
<ProjectHeader
project={detail}
connections={connections as unknown as ConnectionSafe[]}
/>
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5">
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<FileText className="h-4 w-4 text-cyan-300" />
Beschreibung
</h2>
{detail.description ? (
<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.
</p>
)}
</section>
<NotesPanel projectId={detail.id} notes={detail.notes} />
</div>
<div className="lg:col-span-7">
<GitPanel project={detail} />
</div>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { getSessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME } from "@/lib/config";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Registrieren" };
}
export default async function RegisterPage() {
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
if (state.needsSetup) redirect("/setup");
if (user) redirect("/");
if (!state.registrationOpen) redirect("/login");
return (
<AuthShell
appName={APP_NAME}
title="Konto anlegen"
subtitle="Deine Projekte, Notizen und Git-Verbindungen sind nur fuer dich sichtbar."
>
<RegisterForm />
</AuthShell>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AuthShell } from "@/components/auth/AuthShell";
import { SetupForm } from "@/components/auth/SetupForm";
import { getInstanceState } from "@/lib/auth/settings";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> {
return { title: "Einrichtung" };
}
export default async function SetupPage() {
const state = await getInstanceState();
// Nach der Einrichtung ist diese Seite verschlossen.
if (!state.needsSetup) redirect("/login");
// Bestandsdaten anzeigen, damit klar ist, was das neue Konto uebernimmt.
const [orphanProjects, orphanConnections] = await Promise.all([
prisma.project.count({ where: { ownerId: null } }),
prisma.gitConnection.count({ where: { ownerId: null } }),
]);
return (
<AuthShell
appName={APP_NAME}
title="Erste Einrichtung"
subtitle="Lege das Administratorkonto an. Danach ist diese Seite gesperrt."
footer={
orphanProjects + orphanConnections > 0 ? (
<>
{orphanProjects} vorhandene Projekte und {orphanConnections} Git-Verbindungen
werden diesem Konto zugeordnet.
</>
) : null
}
>
<SetupForm />
</AuthShell>
);
}
+447
View File
@@ -0,0 +1,447 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
CheckCircle2,
ExternalLink,
KeyRound,
Loader2,
Pencil,
Plug,
Plus,
Trash2,
XCircle,
Zap,
} from "lucide-react";
import type { GitProvider } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { PROVIDER_META } from "@/lib/git/meta";
import { cn, formatRelative } from "@/lib/utils";
import type { ConnectionWithCount } from "@/types";
const PROVIDERS: GitProvider[] = ["GITHUB", "GITLAB", "GITEA"];
interface FormState {
name: string;
provider: GitProvider;
baseUrl: string;
username: string;
token: string;
}
const EMPTY: FormState = {
name: "",
provider: "GITHUB",
baseUrl: PROVIDER_META.GITHUB.defaultBaseUrl,
username: "",
token: "",
};
export function ConnectionsManager({ connections }: { connections: ConnectionWithCount[] }) {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<ConnectionWithCount | null>(null);
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [checkResult, setCheckResult] = useState<Record<string, { ok: boolean; message: string }>>({});
function openCreate() {
setEditing(null);
setForm(EMPTY);
setError(null);
setDialogOpen(true);
}
function openEdit(connection: ConnectionWithCount) {
setEditing(connection);
setForm({
name: connection.name,
provider: connection.provider,
baseUrl: connection.baseUrl,
username: connection.username ?? "",
token: "",
});
setError(null);
setDialogOpen(true);
}
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((previous) => {
if (key === "provider") {
const provider = value as GitProvider;
const wasDefault = Object.values(PROVIDER_META).some(
(meta) => meta.defaultBaseUrl === previous.baseUrl,
);
return {
...previous,
provider,
baseUrl: wasDefault || !previous.baseUrl
? PROVIDER_META[provider].defaultBaseUrl
: previous.baseUrl,
};
}
return { ...previous, [key]: value };
});
}
async function save(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
// Beim Bearbeiten bedeutet ein leeres Token-Feld "unveraendert lassen".
const body: Record<string, unknown> = {
name: form.name,
provider: form.provider,
baseUrl: form.baseUrl,
username: form.username,
};
if (!editing || form.token.trim()) body.token = form.token;
try {
const response = await fetch(
editing ? `/api/connections/${editing.id}` : "/api/connections",
{
method: editing ? "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.");
setDialogOpen(false);
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
async function check(connection: ConnectionWithCount) {
setBusyId(connection.id);
try {
const response = await fetch(`/api/connections/${connection.id}/check`, { method: "POST" });
const payload = await response.json().catch(() => ({}));
setCheckResult((previous) => ({
...previous,
[connection.id]: {
ok: Boolean(payload.ok),
message: payload.message ?? payload.error ?? "Unbekannte Antwort.",
},
}));
router.refresh();
} finally {
setBusyId(null);
}
}
async function remove(connection: ConnectionWithCount) {
const linked = connection._count?.projects ?? 0;
const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : "";
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
setBusyId(connection.id);
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
setBusyId(null);
router.refresh();
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white">Git-Verbindungen</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-400">
Pro Git-System eine Verbindung mit Basis-URL und Access Token. Tokens werden
AES-256-GCM-verschluesselt in der Datenbank abgelegt und verlassen den Server nie.
</p>
</div>
<button type="button" className="btn btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4" />
Verbindung hinzufuegen
</button>
</div>
{connections.length === 0 ? (
<div className="glass fade-up flex flex-col items-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-cyan-500/25 to-violet-500/20 text-cyan-200">
<Plug className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">Noch keine Verbindung</h2>
<p className="mx-auto mt-1 max-w-md text-sm text-slate-400">
Verbinde GitHub, GitLab oder deine eigene Gitea-Instanz, um Branches, Tags,
Commits und Releases direkt im Dashboard zu sehen.
</p>
</div>
<button type="button" className="btn btn-primary" onClick={openCreate}>
<Plus className="h-4 w-4" />
Erste Verbindung anlegen
</button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{connections.map((connection, index) => {
const meta = PROVIDER_META[connection.provider];
const result = checkResult[connection.id];
const ok = result ? result.ok : connection.lastCheckOk;
const message = result?.message ?? connection.lastCheckMessage;
return (
<article
key={connection.id}
className="glass card-glow fade-up relative p-5"
style={
{
"--accent-from": "#22d3ee",
"--accent-to": "#8b5cf6",
animationDelay: `${index * 50}ms`,
} as React.CSSProperties
}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-white">
{connection.name}
</h2>
<p className="mt-0.5 text-xs text-slate-500">{meta.label}</p>
</div>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] ring-1 ring-inset",
ok === true
? "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30"
: ok === false
? "bg-rose-500/12 text-rose-200 ring-rose-400/30"
: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
)}
>
{ok === true ? (
<CheckCircle2 className="h-3 w-3" />
) : ok === false ? (
<XCircle className="h-3 w-3" />
) : (
<Zap className="h-3 w-3" />
)}
{ok === true ? "verbunden" : ok === false ? "Fehler" : "ungeprueft"}
</span>
</div>
<dl className="mt-4 space-y-2 text-xs">
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Basis-URL</dt>
<dd className="min-w-0 truncate text-right font-mono text-slate-300">
{connection.baseUrl}
</dd>
</div>
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Token</dt>
<dd className="font-mono text-slate-300">
{connection.tokenHint ? (
<span className="inline-flex items-center gap-1.5">
<KeyRound className="h-3 w-3 text-amber-300/70" />
{connection.tokenHint}
</span>
) : (
<span className="text-slate-600">keines</span>
)}
</dd>
</div>
{connection.username ? (
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Benutzer</dt>
<dd className="truncate text-slate-300">{connection.username}</dd>
</div>
) : null}
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Projekte</dt>
<dd className="tabular-nums text-slate-300">
{connection._count?.projects ?? 0}
</dd>
</div>
{connection.lastCheckedAt ? (
<div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Geprueft</dt>
<dd className="text-slate-300" suppressHydrationWarning>
{formatRelative(connection.lastCheckedAt)}
</dd>
</div>
) : null}
</dl>
{message ? (
<p
className={cn(
"mt-3 rounded-lg px-2.5 py-1.5 text-[11px] leading-snug",
ok ? "bg-emerald-500/8 text-emerald-200/90" : "bg-rose-500/8 text-rose-200/90",
)}
>
{message}
</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/6 pt-3">
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => check(connection)}
disabled={busyId === connection.id}
>
{busyId === connection.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Zap className="h-3.5 w-3.5" />
)}
Testen
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => openEdit(connection)}
>
<Pencil className="h-3.5 w-3.5" />
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger ml-auto px-2.5 py-1.5 text-xs"
onClick={() => remove(connection)}
disabled={busyId === connection.id}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</article>
);
})}
</div>
)}
<Modal
open={dialogOpen}
onClose={() => setDialogOpen(false)}
title={editing ? "Verbindung bearbeiten" : "Neue Git-Verbindung"}
description="Der Token wird verschluesselt gespeichert und nur serverseitig entschluesselt."
>
<form onSubmit={save} className="space-y-4">
<div>
<span className="field-label">Git-System</span>
<div className="grid grid-cols-3 gap-2">
{PROVIDERS.map((provider) => (
<button
key={provider}
type="button"
onClick={() => update("provider", provider)}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all",
form.provider === provider
? "border-violet-400/60 bg-violet-500/12 text-white shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
: "border-white/9 text-slate-400 hover:border-white/20 hover:text-slate-200",
)}
>
{PROVIDER_META[provider].label}
</button>
))}
</div>
</div>
<div>
<label className="field-label" htmlFor="connection-name">
Anzeigename *
</label>
<input
id="connection-name"
className="field"
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="z. B. Firmen-Gitea"
required
maxLength={80}
/>
</div>
<div>
<label className="field-label" htmlFor="connection-url">
Basis-URL *
</label>
<input
id="connection-url"
className="field font-mono text-xs"
value={form.baseUrl}
onChange={(event) => update("baseUrl", event.target.value)}
placeholder="https://git.example.com"
required
/>
<p className="mt-1 text-[11px] text-slate-500">
Ohne /api – Nebula haengt den API-Pfad selbst an.
</p>
</div>
<div>
<label className="field-label" htmlFor="connection-token">
Access Token {editing ? "(leer lassen = unveraendert)" : ""}
</label>
<input
id="connection-token"
className="field font-mono text-xs"
type="password"
value={form.token}
onChange={(event) => update("token", event.target.value)}
placeholder={editing ? "••••••••" : "ghp_… / glpat-… / gitea token"}
autoComplete="off"
/>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
{PROVIDER_META[form.provider].tokenHelp}
<a
href={PROVIDER_META[form.provider].docsUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-violet-300 hover:text-violet-200"
>
Doku
<ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
<div>
<label className="field-label" htmlFor="connection-username">
Benutzername (optional)
</label>
<input
id="connection-username"
className="field"
value={form.username}
onChange={(event) => update("username", event.target.value)}
placeholder="wird beim Test automatisch gefuellt"
/>
</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 justify-end gap-2 pt-1">
<button
type="button"
className="btn btn-ghost"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
</div>
);
}
+476
View File
@@ -0,0 +1,476 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
AlertTriangle,
ExternalLink,
GitBranch,
GitCommitHorizontal,
GitMerge,
GitPullRequest,
HardDrive,
Loader2,
Lock,
Package,
RefreshCw,
ShieldCheck,
Star,
Tag,
} from "lucide-react";
import { asArray, asRecord } from "@/types";
import type {
BranchInfo,
CommitInfo,
ProjectDetail,
ReleaseInfo,
TagInfo,
} from "@/types";
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
const TABS = [
{ key: "branches", label: "Branches", icon: GitBranch },
{ key: "tags", label: "Tags", icon: Tag },
{ key: "commits", label: "Commits", icon: GitCommitHorizontal },
{ key: "releases", label: "Releases", icon: Package },
] as const;
type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) {
const router = useRouter();
const [tab, setTab] = useState<TabKey>("branches");
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const snapshot = project.snapshot;
const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags);
const commits = asArray<CommitInfo>(snapshot?.commits);
const releases = asArray<ReleaseInfo>(snapshot?.releases);
const languages = asRecord(snapshot?.languages);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl);
async function sync() {
setSyncing(true);
setMessage(null);
try {
const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" });
const payload = await response.json().catch(() => ({}));
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
router.refresh();
} catch (caught) {
setMessage({
ok: false,
text: caught instanceof Error ? caught.message : String(caught),
});
} finally {
setSyncing(false);
}
}
if (!hasRepo) {
return (
<div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um
Branches, Tags, Commits und Releases hier zu sehen.
</p>
</div>
);
}
const languageTotal = Object.values(languages).reduce((sum, value) => sum + value, 0);
const topLanguages = Object.entries(languages)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return (
<div className="space-y-4">
{/* Kopf mit Sync */}
<div className="glass flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="truncate font-mono text-sm text-slate-100">
{snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`}
</h2>
{snapshot?.visibility ? (
<span className="inline-flex items-center gap-1 rounded-md bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
{snapshot.visibility === "private" ? (
<Lock className="h-2.5 w-2.5" />
) : (
<ShieldCheck className="h-2.5 w-2.5" />
)}
{snapshot.visibility}
</span>
) : null}
{snapshot?.htmlUrl ? (
<a
href={snapshot.htmlUrl}
target="_blank"
rel="noreferrer noopener"
className="text-slate-500 transition-colors hover:text-violet-300"
aria-label="Repository oeffnen"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}`
: "Noch nie synchronisiert."}
</p>
</div>
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Jetzt synchronisieren
</button>
</div>
{message ? (
<p
className={cn(
"rounded-xl border px-3 py-2 text-sm",
message.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{message.text}
</p>
) : null}
{project.syncError && !message ? (
<p className="flex items-start gap-2 rounded-xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{project.syncError}</span>
</p>
) : null}
{snapshot ? (
<>
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Metric icon={Star} label="Stars" value={formatNumber(snapshot.stars)} />
<Metric icon={GitMerge} label="Forks" value={formatNumber(snapshot.forks)} />
<Metric
icon={GitPullRequest}
label="Offene PRs/MRs"
value={formatNumber(snapshot.openPullRequests)}
/>
<Metric
icon={AlertTriangle}
label="Offene Issues"
value={formatNumber(snapshot.openIssues)}
/>
<Metric icon={GitBranch} label="Branches" value={formatNumber(snapshot.branchCount)} />
<Metric
icon={HardDrive}
label="Groesse"
value={snapshot.sizeKb > 1024 ? `${Math.round(snapshot.sizeKb / 1024)} MB` : `${snapshot.sizeKb} KB`}
/>
</div>
{/* Letzter Commit + Version */}
<div className="grid gap-3 lg:grid-cols-2">
<div className="glass p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Letzter Commit
</p>
{snapshot.lastCommitSha ? (
<>
<p className="mt-2 line-clamp-2 text-sm text-slate-200">
{snapshot.lastCommitMessage}
</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded bg-white/6 px-1.5 py-0.5 font-mono text-[11px] text-violet-200">
{snapshot.lastCommitSha.slice(0, 7)}
</span>
<span>{snapshot.lastCommitAuthor ?? "unbekannt"}</span>
<span suppressHydrationWarning>{formatRelative(snapshot.lastCommitAt)}</span>
</p>
</>
) : (
<p className="mt-2 text-sm text-slate-500">Keine Commit-Daten.</p>
)}
</div>
<div className="glass p-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Aktuelle Version
</p>
<div className="mt-2 flex flex-wrap items-baseline gap-3">
<span className="font-mono text-2xl text-white">
{snapshot.latestTag ?? snapshot.latestRelease ?? "–"}
</span>
{snapshot.latestRelease && snapshot.latestRelease !== snapshot.latestTag ? (
<span className="text-sm text-slate-400">{snapshot.latestRelease}</span>
) : null}
</div>
<p className="mt-1 text-xs text-slate-500">
{snapshot.latestReleaseAt
? `Release vom ${formatDateTime(snapshot.latestReleaseAt)}`
: `${snapshot.tagCount} Tag${snapshot.tagCount === 1 ? "" : "s"} im Repository`}
</p>
</div>
</div>
{/* Sprachen */}
{languageTotal > 0 ? (
<div className="glass p-4">
<p className="mb-2.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
Sprachen
</p>
<div className="flex h-2 overflow-hidden rounded-full bg-white/6">
{topLanguages.map(([name, bytes], index) => (
<span
key={name}
title={`${name} – ${Math.round((bytes / languageTotal) * 100)}%`}
style={{
width: `${(bytes / languageTotal) * 100}%`,
backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length],
}}
/>
))}
</div>
<div className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-slate-400">
{topLanguages.map(([name, bytes], index) => (
<span key={name} className="inline-flex items-center gap-1.5">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: LANGUAGE_COLORS[index % LANGUAGE_COLORS.length] }}
/>
{name}
<span className="text-slate-600">
{Math.round((bytes / languageTotal) * 100)}%
</span>
</span>
))}
</div>
</div>
) : null}
{/* Listen */}
<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 =
key === "branches"
? branches.length
: key === "tags"
? tags.length
: key === "commits"
? commits.length
: releases.length;
return (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs transition-colors",
tab === key
? "bg-white/9 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
<span className="tabular-nums text-slate-600">{count}</span>
</button>
);
})}
</div>
<div className="max-h-[26rem] overflow-y-auto">
{tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null}
{tab === "commits" ? <CommitList commits={commits} /> : null}
{tab === "releases" ? <ReleaseList releases={releases} /> : null}
</div>
</div>
</>
) : (
<div className="glass px-5 py-10 text-center text-sm text-slate-500">
Noch keine Daten geladen. Mit „Jetzt synchronisieren“ den ersten Snapshot holen.
</div>
)}
</div>
);
}
const LANGUAGE_COLORS = ["#a78bfa", "#22d3ee", "#34d399", "#fbbf24", "#f472b6"];
function Metric({
icon: Icon,
label,
value,
}: {
icon: typeof Star;
label: string;
value: string;
}) {
return (
<div className="glass-soft px-3 py-2.5">
<p className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-slate-500">
<Icon className="h-3 w-3" />
{label}
</p>
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
</div>
);
}
function Row({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-white/4 px-4 py-2.5 last:border-b-0 hover:bg-white/3">
{children}
</div>
);
}
function Empty({ text }: { text: string }) {
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
}
function BranchList({ branches }: { branches: BranchInfo[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return (
<div>
{branches.map((branch) => (
<Row key={branch.name}>
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{branch.name}</span>
{branch.isDefault ? (
<span className="shrink-0 rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-200">
default
</span>
) : null}
{branch.protected ? (
<Lock className="h-3 w-3 shrink-0 text-amber-300/70" />
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{branch.lastCommitAt ? (
<span suppressHydrationWarning>{formatRelative(branch.lastCommitAt)}</span>
) : null}
{branch.sha ? (
<span className="font-mono text-slate-600">{branch.sha.slice(0, 7)}</span>
) : null}
</div>
</Row>
))}
</div>
);
}
function TagList({ tags }: { tags: TagInfo[] }) {
if (tags.length === 0) return <Empty text="Keine Tags vorhanden." />;
return (
<div>
{tags.map((tag) => (
<Row key={tag.name}>
<div className="flex min-w-0 items-center gap-2">
<Tag className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate font-mono text-xs text-slate-200">{tag.name}</span>
{tag.message ? (
<span className="truncate text-[11px] text-slate-500">{tag.message}</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 text-[11px] text-slate-500">
{tag.date ? <span suppressHydrationWarning>{formatRelative(tag.date)}</span> : null}
{tag.sha ? (
<span className="font-mono text-slate-600">{tag.sha.slice(0, 7)}</span>
) : null}
</div>
</Row>
))}
</div>
);
}
function CommitList({ commits }: { commits: CommitInfo[] }) {
if (commits.length === 0) return <Empty text="Keine Commits geladen." />;
return (
<div>
{commits.map((commit) => (
<Row key={commit.sha}>
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">{commit.message}</p>
<p className="mt-0.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="font-mono text-violet-300/80">{commit.shortSha}</span>
<span>{commit.author ?? "unbekannt"}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(commit.date)}</span>
{commit.url ? (
<a
href={commit.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Commit oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
function ReleaseList({ releases }: { releases: ReleaseInfo[] }) {
if (releases.length === 0) return <Empty text="Keine Releases veroeffentlicht." />;
return (
<div>
{releases.map((release) => (
<Row key={`${release.tag}-${release.name}`}>
<div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-slate-600" />
<span className="truncate text-xs text-slate-200">{release.name}</span>
<span className="shrink-0 rounded bg-white/6 px-1.5 py-0.5 font-mono text-[10px] text-slate-400">
{release.tag}
</span>
{release.prerelease ? (
<span className="shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-200">
pre
</span>
) : null}
{release.draft ? (
<span className="shrink-0 rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-300">
draft
</span>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(release.publishedAt)}</span>
{release.url ? (
<a
href={release.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Release oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
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 { cn, formatDateTime, formatRelative } from "@/lib/utils";
/** Nur als "bearbeitet" zaehlen, wenn wirklich spaeter geaendert wurde. */
function wasEdited(note: Note): boolean {
const created = new Date(note.createdAt).getTime();
const updated = new Date(note.updatedAt).getTime();
return updated - created > 1500;
}
export function NotesPanel({ projectId, notes }: { projectId: string; notes: Note[] }) {
const router = useRouter();
const [composerOpen, setComposerOpen] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
function resetComposer() {
setTitle("");
setContent("");
setEditingId(null);
setComposerOpen(false);
setError(null);
}
async function save(event: React.FormEvent) {
event.preventDefault();
if (!content.trim()) return;
setSaving(true);
setError(null);
try {
const response = await fetch(
editingId ? `/api/notes/${editingId}` : `/api/projects/${projectId}/notes`,
{
method: editingId ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
},
);
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
}
resetComposer();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
async function togglePin(note: Note) {
setBusyId(note.id);
await fetch(`/api/notes/${note.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinned: !note.pinned }),
}).catch(() => undefined);
setBusyId(null);
router.refresh();
}
async function remove(note: Note) {
if (!window.confirm("Notiz wirklich loeschen?")) return;
setBusyId(note.id);
await fetch(`/api/notes/${note.id}`, { method: "DELETE" }).catch(() => undefined);
setBusyId(null);
router.refresh();
}
function startEdit(note: Note) {
setEditingId(note.id);
setTitle(note.title ?? "");
setContent(note.content);
setComposerOpen(true);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<NotebookPen className="h-4 w-4 text-violet-300" />
Notizen
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{notes.length}
</span>
</h2>
{!composerOpen ? (
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setComposerOpen(true)}
>
<Plus className="h-3.5 w-3.5" />
Notiz
</button>
) : null}
</div>
{composerOpen ? (
<form onSubmit={save} className="glass fade-up space-y-3 p-4">
<input
className="field"
placeholder="Titel (optional)"
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={160}
/>
<textarea
className="field min-h-32 resize-y font-mono text-[13px] leading-relaxed"
placeholder="Gedanken, Todos, Snippets… Zeilenumbrueche bleiben erhalten."
value={content}
onChange={(event) => setContent(event.target.value)}
autoFocus
required
/>
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={resetComposer}>
<X className="h-4 w-4" />
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || !content.trim()}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{editingId ? "Aktualisieren" : "Speichern"}
</button>
</div>
</form>
) : null}
{notes.length === 0 && !composerOpen ? (
<div className="glass-soft px-4 py-10 text-center text-sm text-slate-500">
Noch keine Notizen. Halte fest, was dir zu diesem Projekt durch den Kopf geht.
</div>
) : null}
<div className="space-y-3">
{notes.map((note) => (
<article
key={note.id}
className={cn(
"glass group relative p-4 transition-colors",
note.pinned && "ring-1 ring-inset ring-amber-400/25",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
{note.title ? (
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-100">
{note.pinned ? <Pin className="h-3.5 w-3.5 text-amber-300" /> : null}
{note.title}
</h3>
) : null}
<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}
</p>
</div>
<div className="flex shrink-0 items-center gap-1 sm:opacity-0 sm:transition-opacity sm:group-hover:opacity-100">
{busyId === note.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
onClick={() => togglePin(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-amber-300"
aria-label={note.pinned ? "Loesen" : "Anpinnen"}
title={note.pinned ? "Loesen" : "Anpinnen"}
>
{note.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => startEdit(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
aria-label="Bearbeiten"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => remove(note)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
aria-label="Loeschen"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</article>
))}
</div>
</div>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useMemo, useState } from "react";
import { LayoutGrid, ListFilter, Plus, Rows3, Search, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types";
type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped";
const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert",
created: "Neueste zuerst",
name: "Name (A–Z)",
progress: "Fortschritt",
priority: "Prioritaet",
};
export function ProjectBoard({
projects,
connections,
}: {
projects: ProjectListItem[];
connections: ConnectionSafe[];
}) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<ProjectStatus[]>([]);
const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>();
for (const project of projects) {
map.set(project.status, (map.get(project.status) ?? 0) + 1);
}
return map;
}, [projects]);
const visible = useMemo(() => {
const needle = query.toLowerCase().trim();
const filtered = projects.filter((project) => {
if (!showArchived && project.status === "ARCHIVED" && !statusFilter.includes("ARCHIVED")) {
return false;
}
if (statusFilter.length > 0 && !statusFilter.includes(project.status)) return false;
if (!needle) return true;
return (
project.name.toLowerCase().includes(needle) ||
(project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle)
);
});
const sorted = [...filtered].sort((a, b) => {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
switch (sort) {
case "name":
return a.name.localeCompare(b.name, "de");
case "progress":
return b.progress - a.progress;
case "priority":
return b.priority - a.priority;
case "created":
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
default:
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
});
return sorted;
}, [projects, query, statusFilter, sort, showArchived]);
const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({
status,
items: visible.filter((project) => project.status === status),
})).filter((group) => group.items.length > 0);
}, [visible]);
function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) =>
current.includes(status)
? current.filter((entry) => entry !== status)
: [...current, status],
);
}
const hasFilters = query.length > 0 || statusFilter.length > 0;
return (
<section className="space-y-5">
{/* Steuerleiste */}
<div className="glass flex flex-col gap-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
placeholder="Projekte, Tags oder Repos durchsuchen…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
aria-label="Suche zuruecksetzen"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor="sort">
Sortierung
</label>
<select
id="sort"
className="field w-auto min-w-[11rem]"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABEL[key]}
</option>
))}
</select>
<div className="flex overflow-hidden rounded-xl border border-white/9">
<button
type="button"
onClick={() => setView("grid")}
className={cn(
"p-2 transition-colors",
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Rasteransicht"
title="Rasteransicht"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("grouped")}
className={cn(
"p-2 transition-colors",
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Nach Status gruppiert"
title="Nach Status gruppiert"
>
<Rows3 className="h-4 w-4" />
</button>
</div>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span>
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
<ListFilter className="h-3.5 w-3.5" />
Status
</span>
{STATUS_ORDER.map((status) => {
const meta = STATUS_META[status];
const active = statusFilter.includes(status);
const count = counts.get(status) ?? 0;
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
active
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: active ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{count}</span>
</button>
);
})}
<label className="ml-auto flex cursor-pointer items-center gap-2 text-xs text-slate-500">
<input
type="checkbox"
checked={showArchived}
onChange={(event) => setShowArchived(event.target.checked)}
className="h-3.5 w-3.5 accent-violet-500"
/>
Archiv einblenden
</label>
{hasFilters ? (
<button
type="button"
onClick={() => {
setQuery("");
setStatusFilter([]);
}}
className="text-xs text-violet-300 underline-offset-2 hover:underline"
>
Filter zuruecksetzen
</button>
) : null}
</div>
</div>
{/* Inhalt */}
{visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
) : (
<div className="space-y-8">
{grouped.map((group) => {
const meta = STATUS_META[group.status];
return (
<div key={group.status}>
<div className="mb-3 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{group.items.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} />
))}
</div>
</div>
);
})}
</div>
)}
<ProjectDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
connections={connections}
/>
</section>
);
}
function EmptyState({
hasFilters,
onCreate,
}: {
hasFilters: boolean;
onCreate: () => void;
}) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-20 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<Plus className="h-6 w-6" />
</span>
<div>
<h3 className="text-lg font-semibold text-white">
{hasFilters ? "Keine Treffer" : "Noch keine Projekte"}
</h3>
<p className="mt-1 max-w-md text-sm text-slate-400">
{hasFilters
? "Kein Projekt passt zu Suche und Filtern. Filter anpassen oder zuruecksetzen."
: "Leg deine erste Idee an – Beschreibung, Repo und Status kannst du jederzeit nachziehen."}
</p>
</div>
{!hasFilters ? (
<button type="button" className="btn btn-primary" onClick={onCreate}>
<Plus className="h-4 w-4" />
Erstes Projekt anlegen
</button>
) : null}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
Tag,
} from "lucide-react";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub",
GITLAB: "GitLab",
GITEA: "Gitea",
};
/**
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) {
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot;
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
const next = !favorite;
setFavorite(next);
try {
const response = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: next }),
});
if (!response.ok) throw new Error("failed");
} catch {
setFavorite(!next);
}
}
return (
<article
className="glass card-glow fade-up group relative overflow-hidden p-5"
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
animationDelay: `${Math.min(index, 12) * 40}ms`,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
<Link
href={`/projects/${project.id}`}
className="absolute inset-0 z-0 rounded-[var(--radius-card)] focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
aria-label={`Projekt ${project.name} oeffnen`}
/>
<div className="pointer-events-none relative z-10">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name}
</h3>
{favorite ? (
<Star className="h-3.5 w-3.5 shrink-0 fill-amber-300 text-amber-300" />
) : null}
</div>
<p className="mt-1 line-clamp-2 min-h-[2.4rem] text-sm leading-snug text-slate-400">
{project.summary ? truncate(project.summary, 140) : "Noch keine Kurzbeschreibung."}
</p>
</div>
<button
type="button"
onClick={toggleFavorite}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"pointer-events-auto shrink-0 rounded-lg p-1.5 transition-colors",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300 sm:opacity-0 sm:group-hover:opacity-100",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
</div>
{/* Fortschritt */}
<div className="mt-4">
<div className="mb-1.5 flex items-center justify-between text-[11px] text-slate-500">
<span>Fortschritt</span>
<span className="tabular-nums text-slate-400">{project.progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full transition-[width] duration-700"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</div>
</div>
{/* Git-Kurzinfo */}
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
{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" />
{PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider}
</span>
) : null}
{snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1.5 font-mono">
<GitBranch className="h-3.5 w-3.5 text-slate-500" />
{snapshot.defaultBranch}
{snapshot.branchCount > 1 ? (
<span className="text-slate-600">+{snapshot.branchCount - 1}</span>
) : null}
</span>
) : null}
{snapshot?.latestTag ? (
<span className="inline-flex items-center gap-1.5 font-mono">
<Tag className="h-3.5 w-3.5 text-slate-500" />
{snapshot.latestTag}
</span>
) : null}
{snapshot?.lastCommitAt ? (
<span className="inline-flex items-center gap-1.5" suppressHydrationWarning>
<GitCommitHorizontal className="h-3.5 w-3.5 text-slate-500" />
{formatRelative(snapshot.lastCommitAt)}
</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" />
{project._count.notes}
</span>
) : null}
{project.syncError ? (
<span
className="inline-flex items-center gap-1.5 text-rose-300/80"
title={project.syncError}
>
<AlertTriangle className="h-3.5 w-3.5" />
Sync-Fehler
</span>
) : null}
</div>
{/* Tags */}
{project.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{project.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="rounded-md bg-white/5 px-1.5 py-0.5 text-[10px] font-medium text-slate-400"
>
#{tag}
</span>
))}
{project.tags.length > 4 ? (
<span className="px-1 text-[10px] text-slate-600">+{project.tags.length - 4}</span>
) : null}
</div>
) : null}
<div className="mt-4 flex items-center justify-between gap-2 border-t border-white/6 pt-3">
<div className="pointer-events-auto">
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
</div>
<span
className="text-[10px] uppercase tracking-wider"
style={{ color: priority.color }}
title={`Prioritaet: ${priority.label}`}
>
{priority.label}
</span>
</div>
</div>
</article>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem, RepoListEntry } from "@/types";
interface ProjectDialogProps {
open: boolean;
onClose: () => void;
connections: ConnectionSafe[];
/** Vorhandenes Projekt = Bearbeiten, sonst Anlegen. */
project?: ProjectListItem | null;
}
interface FormState {
name: string;
summary: string;
description: string;
status: ProjectStatus;
priority: number;
progress: number;
accent: string;
tags: string;
favorite: boolean;
connectionId: string;
repoUrl: string;
repoOwner: string;
repoName: string;
}
const EMPTY: FormState = {
name: "",
summary: "",
description: "",
status: "IDEA",
priority: 2,
progress: 0,
accent: "violet",
tags: "",
favorite: false,
connectionId: "",
repoUrl: "",
repoOwner: "",
repoName: "",
};
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
const router = useRouter();
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => {
if (!open) return;
setError(null);
setFieldErrors({});
setRepoPickerOpen(false);
setRepos([]);
setRepoError(null);
setForm(
project
? {
name: project.name,
summary: project.summary ?? "",
description: project.description ?? "",
status: project.status,
priority: project.priority,
progress: project.progress,
accent: project.accent,
tags: project.tags.join(", "),
favorite: project.favorite,
connectionId: project.connectionId ?? "",
repoUrl: project.repoUrl ?? "",
repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "",
}
: EMPTY,
);
}, [open, project]);
const filteredRepos = useMemo(() => {
const needle = repoFilter.toLowerCase().trim();
if (!needle) return repos.slice(0, 60);
return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60);
}, [repos, repoFilter]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((previous) => ({ ...previous, [key]: value }));
}
async function loadRepos() {
if (!form.connectionId) {
setRepoError("Bitte zuerst eine Git-Verbindung auswaehlen.");
return;
}
setReposLoading(true);
setRepoError(null);
try {
const response = await fetch(`/api/connections/${form.connectionId}/repos`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden.");
setRepos(payload.repos ?? []);
setRepoPickerOpen(true);
} catch (caught) {
setRepoError(caught instanceof Error ? caught.message : String(caught));
} finally {
setReposLoading(false);
}
}
function pickRepo(repo: RepoListEntry) {
setForm((previous) => ({
...previous,
repoUrl: repo.htmlUrl,
repoOwner: repo.owner,
repoName: repo.name,
name: previous.name || repo.name,
summary: previous.summary || (repo.description ?? ""),
}));
setRepoPickerOpen(false);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
setFieldErrors({});
const body = {
name: form.name,
summary: form.summary,
description: form.description,
status: form.status,
priority: Number(form.priority),
progress: Number(form.progress),
accent: form.accent,
tags: parseTags(form.tags),
favorite: form.favorite,
connectionId: form.connectionId || null,
repoUrl: form.repoUrl,
repoOwner: form.repoOwner,
repoName: form.repoName,
};
try {
const response = await fetch(
isEdit ? `/api/projects/${project?.id}` : "/api/projects",
{
method: isEdit ? "PATCH" : "POST",
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.");
}
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="xl"
title={isEdit ? "Projekt bearbeiten" : "Neues Projekt"}
description={
isEdit
? "Aenderungen wirken sofort im Board."
: "Eine reine Idee reicht – Repo und Details lassen sich jederzeit nachtragen."
}
>
<form onSubmit={submit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name">
Name *
</label>
<input
id="project-name"
className="field"
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="z. B. Pixelgarten"
required
maxLength={120}
autoFocus
/>
{fieldErrors.name ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.name}</p>
) : null}
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-summary">
Kurzbeschreibung
</label>
<input
id="project-summary"
className="field"
value={form.summary}
onChange={(event) => update("summary", event.target.value)}
placeholder="Ein Satz, der auf die Karte passt"
maxLength={280}
/>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-description">
Beschreibung / Ideenskizze
</label>
<textarea
id="project-description"
className="field min-h-28 resize-y"
value={form.description}
onChange={(event) => update("description", event.target.value)}
placeholder="Worum geht es? Was ist der Reiz? Welcher Stack?"
/>
</div>
<div>
<label className="field-label" htmlFor="project-status">
Status
</label>
<select
id="project-status"
className="field"
value={form.status}
onChange={(event) => update("status", event.target.value as ProjectStatus)}
>
{STATUS_ORDER.map((status) => (
<option key={status} value={status}>
{STATUS_META[status].label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="project-priority">
Prioritaet
</label>
<select
id="project-priority"
className="field"
value={form.priority}
onChange={(event) => update("priority", Number(event.target.value))}
>
{Object.entries(PRIORITY_META).map(([value, meta]) => (
<option key={value} value={value}>
{meta.label}
</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-progress">
Fortschritt: {form.progress}%
</label>
<input
id="project-progress"
type="range"
min={0}
max={100}
step={5}
value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))}
className="w-full accent-violet-500"
/>
</div>
<div className="sm:col-span-2">
<span className="field-label">Akzentfarbe</span>
<div className="flex flex-wrap gap-2">
{Object.entries(ACCENTS).map(([key, accent]) => (
<button
key={key}
type="button"
onClick={() => update("accent", key)}
aria-label={accent.label}
title={accent.label}
className={cn(
"h-8 w-8 rounded-lg transition-transform",
form.accent === key
? "scale-110 ring-2 ring-white/70"
: "opacity-70 hover:scale-105 hover:opacity-100",
)}
style={{
backgroundImage: `linear-gradient(135deg, ${accent.from}, ${accent.to})`,
}}
/>
))}
</div>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="project-tags">
Tags (kommagetrennt)
</label>
<input
id="project-tags"
className="field"
value={form.tags}
onChange={(event) => update("tags", event.target.value)}
placeholder="next.js, spiel, experiment"
/>
</div>
</div>
{/* ── Git ────────────────────────────────────────────── */}
<div className="glass-soft space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-200">Git-Anbindung (optional)</h3>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={loadRepos}
disabled={reposLoading || !form.connectionId}
>
{reposLoading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Repos laden
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="project-connection">
Verbindung
</label>
<select
id="project-connection"
className="field"
value={form.connectionId}
onChange={(event) => update("connectionId", event.target.value)}
>
<option value="">– keine –</option>
{connections.map((connection) => (
<option key={connection.id} value={connection.id}>
{connection.name} ({connection.provider})
</option>
))}
</select>
{connections.length === 0 ? (
<p className="mt-1 text-xs text-slate-500">
Noch keine Verbindung angelegt – unter „Git-Verbindungen“ hinzufuegen.
</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="project-repo-url">
Repository-URL
</label>
<input
id="project-repo-url"
className="field"
value={form.repoUrl}
onChange={(event) => update("repoUrl", event.target.value)}
placeholder="https://github.com/user/repo"
/>
</div>
<div>
<label className="field-label" htmlFor="project-repo-owner">
Owner / Gruppe
</label>
<input
id="project-repo-owner"
className="field font-mono text-xs"
value={form.repoOwner}
onChange={(event) => update("repoOwner", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
<div>
<label className="field-label" htmlFor="project-repo-name">
Repository
</label>
<input
id="project-repo-name"
className="field font-mono text-xs"
value={form.repoName}
onChange={(event) => update("repoName", event.target.value)}
placeholder="wird aus der URL abgeleitet"
/>
</div>
</div>
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
{repoPickerOpen ? (
<div className="rounded-xl border border-white/8 bg-[#080a18]/70">
<div className="flex items-center gap-2 border-b border-white/6 px-3 py-2">
<Search className="h-3.5 w-3.5 text-slate-500" />
<input
className="w-full bg-transparent text-sm text-slate-200 outline-none placeholder:text-slate-600"
placeholder={`${repos.length} Repositories durchsuchen…`}
value={repoFilter}
onChange={(event) => setRepoFilter(event.target.value)}
/>
</div>
<ul className="max-h-56 overflow-y-auto p-1">
{filteredRepos.map((repo) => (
<li key={repo.fullPath}>
<button
type="button"
onClick={() => pickRepo(repo)}
className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left hover:bg-white/6"
>
<span className="min-w-0">
<span className="block truncate font-mono text-xs text-slate-200">
{repo.fullPath}
</span>
{repo.description ? (
<span className="block truncate text-[11px] text-slate-500">
{repo.description}
</span>
) : null}
</span>
<span className="shrink-0 text-[10px] uppercase text-slate-600">
{repo.visibility}
</span>
</button>
</li>
))}
{filteredRepos.length === 0 ? (
<li className="px-3 py-4 text-center text-xs text-slate-500">
Keine Treffer.
</li>
) : null}
</ul>
</div>
) : 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}
<div className="flex items-center justify-between gap-3 pt-1">
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
<input
type="checkbox"
checked={form.favorite}
onChange={(event) => update("favorite", event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Als Favorit anpinnen
</label>
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={saving}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Projekt anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
ArrowLeft,
CalendarDays,
ExternalLink,
Loader2,
Pencil,
Star,
Trash2,
} from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatDate } from "@/lib/utils";
import type { ConnectionSafe, ProjectDetail } from "@/types";
export function ProjectHeader({
project,
connections,
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
}) {
const router = useRouter();
const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false);
const [favorite, setFavorite] = useState(project.favorite);
const [progress, setProgress] = useState(project.progress);
const [deleting, setDeleting] = useState(false);
async function patch(payload: Record<string, unknown>) {
await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined);
router.refresh();
}
async function remove() {
if (
!window.confirm(
`„${project.name}“ mit allen Notizen unwiderruflich loeschen?`,
)
) {
return;
}
setDeleting(true);
const response = await fetch(`/api/projects/${project.id}`, { method: "DELETE" });
if (response.ok) {
router.push("/");
router.refresh();
} else {
setDeleting(false);
}
}
return (
<header
className="glass relative overflow-hidden p-6"
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
<div
className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full opacity-20 blur-3xl"
style={{ background: `radial-gradient(circle, ${accent.from}, transparent 70%)` }}
aria-hidden="true"
/>
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurueck zum Dashboard
</Link>
<div className="mt-3 flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{project.name}
</h1>
<button
type="button"
onClick={() => {
const next = !favorite;
setFavorite(next);
void patch({ favorite: next });
}}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"rounded-lg p-1.5 transition-colors",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
</div>
<p className="mt-1 font-mono text-xs text-slate-600">/{project.slug}</p>
{project.summary ? (
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-slate-300">
{project.summary}
</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-500">
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
angelegt {formatDate(project.createdAt)}
</span>
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
{project.connection ? (
<span className="inline-flex items-center gap-1.5">
{project.connection.name}
<span className="text-slate-700">·</span>
{project.connection.provider}
</span>
) : null}
{project.repoUrl ? (
<a
href={project.repoUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
>
Repository
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
{project.tags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{project.tags.map((tag) => (
<span
key={tag}
className="rounded-md bg-white/5 px-2 py-0.5 text-[11px] text-slate-400"
>
#{tag}
</span>
))}
</div>
) : null}
</div>
<div className="flex flex-col items-end gap-3">
<StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2">
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setEditOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={deleting}
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Loeschen
</button>
</div>
</div>
</div>
{/* Fortschritt direkt regelbar */}
<div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span>Fortschritt</span>
<span className="tabular-nums text-slate-300">{progress}%</span>
</div>
<div className="relative">
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.max(1, progress)}%` }}
/>
</div>
<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>
<ProjectDialog
open={editOpen}
onClose={() => setEditOpen(false)}
connections={connections}
project={project}
/>
</header>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { LucideIcon } from "lucide-react";
interface StatCardProps {
label: string;
value: number | string;
hint?: string;
icon: LucideIcon;
color: string;
}
export function StatCard({ label, value, hint, icon: Icon, color }: StatCardProps) {
return (
<div className="glass relative overflow-hidden p-4">
<div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
{label}
</p>
<p className="mt-2 text-3xl font-semibold tabular-nums text-white">{value}</p>
{hint ? <p className="mt-1 truncate text-xs text-slate-500">{hint}</p> : null}
</div>
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
style={{ backgroundColor: `${color}1f`, color }}
>
<Icon className="h-4 w-4" />
</span>
</div>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Check, ChevronDown, Loader2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
import { cn } from "@/lib/utils";
interface StatusSelectProps {
projectId: string;
status: ProjectStatus;
/** compact: nur Punkt + Kurzlabel, fuer die Karten im Board. */
variant?: "compact" | "full";
onChanged?: (status: ProjectStatus) => void;
}
export function StatusSelect({
projectId,
status,
variant = "full",
onChanged,
}: StatusSelectProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState<ProjectStatus>(status);
const [pending, startTransition] = useTransition();
const [saving, setSaving] = useState(false);
const meta = statusOf(current);
async function select(next: ProjectStatus) {
setOpen(false);
if (next === current) return;
const previous = current;
setCurrent(next); // optimistisch
setSaving(true);
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: next }),
});
if (!response.ok) throw new Error(await response.text());
onChanged?.(next);
startTransition(() => router.refresh());
} catch {
setCurrent(previous); // zurueckrollen
} finally {
setSaving(false);
}
}
return (
<div className="relative">
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen((value) => !value);
}}
className={cn(
"inline-flex items-center gap-1.5 rounded-full font-medium ring-1 ring-inset transition-all hover:brightness-125",
meta.className,
variant === "compact" ? "px-2.5 py-1 text-[11px]" : "px-3 py-1.5 text-xs",
)}
aria-haspopup="listbox"
aria-expanded={open}
>
{saving || pending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span
className="accent-dot h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
)}
{variant === "compact" ? meta.short : meta.label}
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
{open ? (
<>
<button
type="button"
aria-label="Auswahl schliessen"
className="fixed inset-0 z-30 cursor-default"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(false);
}}
/>
<div
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>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useEffect, 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 type { SessionUser } from "@/lib/auth/session";
import { cn } from "@/lib/utils";
const LINKS = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
];
export function TopNav({
user,
appName,
tagline,
}: {
user: SessionUser | null;
appName: string;
tagline: string;
}) {
const pathname = usePathname();
return (
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-6 px-4 py-3 sm:px-6 lg:px-8">
<Link href="/" className="group flex items-center gap-3">
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
</span>
<span className="flex flex-col leading-none">
<span className="text-gradient text-lg font-semibold tracking-tight">
{appName}
</span>
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500">
{tagline}
</span>
</span>
</Link>
{user ? (
<>
<nav className="flex items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-white/8 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-100",
)}
>
<Icon className="h-4 w-4" />
<span className="hidden sm:inline">{label}</span>
</Link>
);
})}
</nav>
<div className="ml-auto">
<UserMenu user={user} />
</div>
</>
) : null}
</div>
</header>
);
}
function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
async function logout() {
setBusy(true);
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
setOpen(false);
router.replace("/login");
router.refresh();
}
const label = user.displayName?.trim() || user.username;
const initials = label.slice(0, 2).toUpperCase();
return (
<div className="relative" ref={containerRef}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
aria-haspopup="menu"
aria-expanded={open}
>
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
{initials}
</span>
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
{user.role === "ADMIN" ? (
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
) : null}
</button>
{open ? (
<div
role="menu"
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
>
<div className="border-b border-white/6 px-3 py-2">
<p className="truncate text-sm text-slate-100">{label}</p>
<p className="truncate text-[11px] text-slate-500">
@{user.username}
{user.role === "ADMIN" ? " · Administrator" : ""}
</p>
</div>
<Link
href="/account"
role="menuitem"
onClick={() => setOpen(false)}
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<UserRound className="h-4 w-4" />
Mein Konto
</Link>
{user.role === "ADMIN" ? (
<Link
href="/admin"
role="menuitem"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
>
<Settings className="h-4 w-4" />
Admin-Einstellungen
</Link>
) : null}
<button
type="button"
role="menuitem"
onClick={logout}
disabled={busy}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
>
<LogOut className="h-4 w-4" />
Abmelden
</button>
</div>
) : null}
</div>
);
}
+530
View File
@@ -0,0 +1,530 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { startRegistration } from "@simplewebauthn/browser";
import {
Fingerprint,
KeyRound,
Loader2,
LogOut,
Monitor,
Pencil,
Save,
ShieldCheck,
Trash2,
} from "lucide-react";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
export interface PasskeyEntry {
id: string;
name: string | null;
deviceType: string | null;
backedUp: boolean;
createdAt: string | Date;
lastUsedAt: string | Date | null;
}
export interface SessionEntry {
id: string;
userAgent: string | null;
ip: string | null;
createdAt: string | Date;
lastSeenAt: string | Date;
}
export interface AccountData {
username: string;
displayName: string | null;
email: string | null;
role: "ADMIN" | "USER";
hasPassword: boolean;
passkeys: PasskeyEntry[];
sessions: SessionEntry[];
passkeysAvailable: boolean;
}
type Notice = { ok: boolean; text: string } | null;
export function AccountManager({ data }: { data: AccountData }) {
return (
<div className="space-y-6">
<ProfileSection data={data} />
<PasswordSection hasPassword={data.hasPassword} />
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
<SessionSection sessions={data.sessions} />
</div>
);
}
function SectionCard({
title,
description,
icon: Icon,
children,
}: {
title: string;
description?: string;
icon: typeof KeyRound;
children: React.ReactNode;
}) {
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Icon className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
{title}
</h2>
{description ? (
<p className="mt-1 text-sm text-slate-400">{description}</p>
) : null}
</div>
</div>
{children}
</section>
);
}
function NoticeLine({ notice }: { notice: Notice }) {
if (!notice) return null;
return (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
);
}
function ProfileSection({ data }: { data: AccountData }) {
const router = useRouter();
const [displayName, setDisplayName] = useState(data.displayName ?? "");
const [email, setEmail] = useState(data.email ?? "");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName, email }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
setNotice({ ok: true, text: "Profil gespeichert." });
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<SectionCard title="Profil" icon={Pencil}>
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
<div>
<span className="field-label">Benutzername</span>
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
<p className="mt-1 text-[11px] text-slate-600">
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
</p>
</div>
<div />
<div>
<label className="field-label" htmlFor="account-display">
Anzeigename
</label>
<input
id="account-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="account-email">
E-Mail
</label>
<input
id="account-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="optional"
/>
</div>
<div className="sm:col-span-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Speichern
</button>
<NoticeLine notice={notice} />
</div>
</form>
</SectionCard>
);
}
function PasswordSection({ hasPassword }: { hasPassword: boolean }) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(event: React.FormEvent) {
event.preventDefault();
if (newPassword !== repeat) {
setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." });
return;
}
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/account/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen.");
setNotice({
ok: true,
text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.",
});
setCurrentPassword("");
setNewPassword("");
setRepeat("");
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<SectionCard
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
icon={KeyRound}
description={
hasPassword
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
}
>
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
{hasPassword ? (
<div className="sm:col-span-2">
<label className="field-label" htmlFor="account-current">
Aktuelles Passwort
</label>
<input
id="account-current"
className="field"
type="password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
) : null}
<div>
<label className="field-label" htmlFor="account-new">
Neues Passwort
</label>
<input
id="account-new"
className="field"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
autoComplete="new-password"
minLength={PASSWORD_MIN_LENGTH}
required
/>
</div>
<div>
<label className="field-label" htmlFor="account-repeat">
Wiederholen
</label>
<input
id="account-repeat"
className="field"
type="password"
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
autoComplete="new-password"
required
/>
</div>
<div className="sm:col-span-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
</button>
<NoticeLine notice={notice} />
</div>
</form>
</SectionCard>
);
}
function PasskeySection({
passkeys,
available,
}: {
passkeys: PasskeyEntry[];
available: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice>(null);
async function addPasskey() {
setBusy(true);
setNotice(null);
try {
const optionsResponse = await fetch("/api/auth/passkey/register/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const optionsPayload = await optionsResponse.json().catch(() => ({}));
if (!optionsResponse.ok) {
throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden.");
}
const attestation = await startRegistration({ optionsJSON: optionsPayload.options });
const suggestion =
typeof navigator !== "undefined" && /mac/i.test(navigator.platform)
? "Mac"
: typeof navigator !== "undefined" && /win/i.test(navigator.platform)
? "Windows"
: "Geraet";
const verifyResponse = await fetch("/api/auth/passkey/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response: attestation, name: suggestion }),
});
const verifyPayload = await verifyResponse.json().catch(() => ({}));
if (!verifyResponse.ok) {
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
}
setNotice({ ok: true, text: "Passkey hinzugefuegt." });
router.refresh();
} catch (caught) {
const message = caught instanceof Error ? caught.message : String(caught);
if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message });
} finally {
setBusy(false);
}
}
async function rename(entry: PasskeyEntry) {
const name = window.prompt("Name des Passkeys", entry.name ?? "");
if (name === null) return;
setBusyId(entry.id);
await fetch(`/api/account/passkeys/${entry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}).catch(() => undefined);
setBusyId(null);
router.refresh();
}
async function remove(entry: PasskeyEntry) {
if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return;
setBusyId(entry.id);
try {
const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusyId(null);
}
}
return (
<SectionCard
title="Passkeys"
icon={Fingerprint}
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
>
{!available ? (
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
oder auf http://localhost zeigen.
</p>
) : (
<>
<div className="space-y-2">
{passkeys.length === 0 ? (
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
) : (
passkeys.map((entry) => (
<div
key={entry.id}
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
>
<div className="min-w-0">
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
{entry.name ?? "Unbenannter Passkey"}
{entry.backedUp ? (
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
synchronisiert
</span>
) : null}
</p>
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
angelegt {formatDateTime(entry.createdAt)}
{entry.lastUsedAt
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
: " · noch nie genutzt"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{busyId === entry.id ? (
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
) : (
<>
<button
type="button"
onClick={() => rename(entry)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
aria-label="Umbenennen"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => remove(entry)}
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
))
)}
</div>
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Passkey hinzufuegen
</button>
</>
)}
<NoticeLine notice={notice} />
</SectionCard>
);
}
function SessionSection({ sessions }: { sessions: SessionEntry[] }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function logoutEverywhere() {
if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return;
setBusy(true);
await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined);
setBusy(false);
router.refresh();
}
return (
<SectionCard
title="Aktive Sitzungen"
icon={Monitor}
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
>
<div className="space-y-2">
{sessions.map((session) => (
<div key={session.id} className="glass-soft px-3 py-2.5">
<p className="truncate text-sm text-slate-300">
{shortenUserAgent(session.userAgent)}
</p>
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
angemeldet {formatDateTime(session.createdAt)}
</p>
</div>
))}
</div>
<button
type="button"
className="btn btn-danger mt-4"
onClick={logoutEverywhere}
disabled={busy || sessions.length <= 1}
>
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
Ueberall abmelden
</button>
{sessions.length <= 1 ? (
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
<ShieldCheck className="h-3 w-3" />
Nur dieses Geraet ist angemeldet.
</p>
) : null}
</SectionCard>
);
}
/** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */
function shortenUserAgent(userAgent: string | null): string {
if (!userAgent) return "Unbekanntes Geraet";
const browser =
/Edg\//.test(userAgent) ? "Edge"
: /OPR\//.test(userAgent) ? "Opera"
: /Firefox\//.test(userAgent) ? "Firefox"
: /Chrome\//.test(userAgent) ? "Chrome"
: /Safari\//.test(userAgent) ? "Safari"
: "Browser";
const platform =
/Windows/.test(userAgent) ? "Windows"
: /Macintosh|Mac OS/.test(userAgent) ? "macOS"
: /Android/.test(userAgent) ? "Android"
: /iPhone|iPad/.test(userAgent) ? "iOS"
: /Linux/.test(userAgent) ? "Linux"
: "";
return platform ? `${browser} auf ${platform}` : browser;
}
+665
View File
@@ -0,0 +1,665 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Ban,
CheckCircle2,
Fingerprint,
FolderGit2,
Loader2,
Lock,
Plus,
ShieldCheck,
Trash2,
User,
UserCog,
Users,
} from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
type Mode = "SINGLE_USER" | "MULTI_USER";
type Role = "ADMIN" | "USER";
export interface AdminUser {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: Role;
isActive: boolean;
lastLoginAt: string | Date | null;
lockedUntil: string | Date | null;
createdAt: string | Date;
_count: { credentials: number; projects: number; sessions: number };
}
export interface AdminData {
mode: Mode;
allowRegistration: boolean;
users: AdminUser[];
currentUserId: string;
appUrl: string;
passkeysAvailable: boolean;
}
type Notice = { ok: boolean; text: string } | null;
export function AdminManager({ data }: { data: AdminData }) {
const router = useRouter();
const [mode, setMode] = useState<Mode>(data.mode);
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setMode(body.settings.mode);
setAllowRegistration(body.settings.allowRegistration);
setNotice({ ok: true, text: "Einstellungen gespeichert." });
router.refresh();
} catch (caught) {
// Umschalter zuruecksetzen, wenn der Server ablehnt.
setMode(data.mode);
setAllowRegistration(data.allowRegistration);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<div className="space-y-6">
{/* ── Betriebsart ─────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<Users className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Betriebsart
</h2>
<p className="mt-1 text-sm text-slate-400">
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<ModeCard
active={mode === "SINGLE_USER"}
disabled={busy}
icon={User}
title="Einzelbetrieb"
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
onClick={() => {
setMode("SINGLE_USER");
void patchSettings({ mode: "SINGLE_USER" });
}}
/>
<ModeCard
active={mode === "MULTI_USER"}
disabled={busy}
icon={Users}
title="Mehrbenutzerbetrieb"
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
onClick={() => {
setMode("MULTI_USER");
void patchSettings({ mode: "MULTI_USER" });
}}
/>
</div>
{mode === "MULTI_USER" ? (
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<input
type="checkbox"
checked={allowRegistration}
disabled={busy}
onChange={(event) => {
setAllowRegistration(event.target.checked);
void patchSettings({ allowRegistration: event.target.checked });
}}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
Internet erreichbaren Instanz besser aus lassen.
</span>
</span>
</label>
) : null}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
{/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
<ShieldCheck className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Instanz
</h2>
<p className="mt-1 text-sm text-slate-400">
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
lassen sich nur dort aendern.
</p>
</div>
</div>
<dl className="grid gap-3 sm:grid-cols-2">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
{data.passkeysAvailable ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
verfuegbar
</>
) : (
<>
<Ban className="h-3.5 w-3.5 text-amber-300" />
kein sicherer Kontext
</>
)}
</dd>
</div>
</dl>
</section>
{/* ── Benutzer ────────────────────────────────────── */}
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
<UserCog className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Benutzer ({data.users.length})
</h2>
<p className="mt-1 text-sm text-slate-400">
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
</p>
</div>
</div>
<button
type="button"
className="btn btn-primary px-3 py-1.5 text-xs"
onClick={() => setCreateOpen(true)}
disabled={mode === "SINGLE_USER"}
title={
mode === "SINGLE_USER"
? "Erst in den Mehrbenutzerbetrieb wechseln"
: undefined
}
>
<Plus className="h-3.5 w-3.5" />
Konto anlegen
</button>
</div>
<div className="space-y-2">
{data.users.map((user) => (
<UserRow
key={user.id}
user={user}
isSelf={user.id === data.currentUserId}
onEdit={() => setEditing(user)}
/>
))}
</div>
</section>
<UserDialog
open={createOpen}
mode="create"
onClose={() => setCreateOpen(false)}
/>
<UserDialog
open={editing !== null}
mode="edit"
user={editing}
isSelf={editing?.id === data.currentUserId}
onClose={() => setEditing(null)}
/>
</div>
);
}
function ModeCard({
active,
disabled,
icon: Icon,
title,
hint,
onClick,
}: {
active: boolean;
disabled: boolean;
icon: typeof User;
title: string;
hint: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
active
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
: "border-white/9 hover:border-white/20",
)}
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
{title}
{active ? (
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
) : null}
</span>
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
</button>
);
}
function UserRow({
user,
isSelf,
onEdit,
}: {
user: AdminUser;
isSelf: boolean;
onEdit: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const locked =
user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now();
async function remove() {
if (
!window.confirm(
`Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`,
)
) {
return;
}
setBusy(true);
setError(null);
try {
const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<div className="glass-soft px-3 py-3">
<div className="flex flex-wrap items-center gap-3">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
user.isActive
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
: "bg-slate-700/60",
)}
>
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
<span className="truncate">{user.displayName ?? user.username}</span>
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
{user.role === "ADMIN" ? (
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
<ShieldCheck className="h-2.5 w-2.5" />
Admin
</span>
) : null}
{isSelf ? (
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
du
</span>
) : null}
{!user.isActive ? (
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
deaktiviert
</span>
) : null}
{locked ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
<Lock className="h-2.5 w-2.5" />
gesperrt
</span>
) : null}
</p>
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span className="inline-flex items-center gap-1">
<FolderGit2 className="h-3 w-3" />
{user._count.projects} Projekte
</span>
<span className="inline-flex items-center gap-1">
<Fingerprint className="h-3 w-3" />
{user._count.credentials} Passkeys
</span>
<span suppressHydrationWarning>
{user.lastLoginAt
? `zuletzt ${formatRelative(user.lastLoginAt)}`
: "noch nie angemeldet"}
</span>
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onEdit}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
disabled={busy}
>
<UserCog className="h-3.5 w-3.5" />
Bearbeiten
</button>
<button
type="button"
onClick={remove}
className="btn btn-danger px-2.5 py-1.5 text-xs"
disabled={busy || isSelf}
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
</div>
);
}
function UserDialog({
open,
mode,
user,
isSelf,
onClose,
}: {
open: boolean;
mode: "create" | "edit";
user?: AdminUser | null;
isSelf?: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<Role>("USER");
const [isActive, setIsActive] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [loadedFor, setLoadedFor] = useState<string | null>(null);
// Formular beim Oeffnen einmalig aus dem Datensatz fuellen.
const key = open ? (user?.id ?? "new") : null;
if (key !== null && key !== loadedFor) {
setLoadedFor(key);
setUsername(user?.username ?? "");
setDisplayName(user?.displayName ?? "");
setEmail(user?.email ?? "");
setPassword("");
setRole(user?.role ?? "USER");
setIsActive(user?.isActive ?? true);
setError(null);
setFieldErrors({});
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldErrors({});
const body =
mode === "create"
? { username, displayName, email, password, role }
: { displayName, email, role, isActive, password: password || null };
try {
const response = await fetch(
mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`,
{
method: mode === "create" ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
}
setLoadedFor(null);
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={() => {
setLoadedFor(null);
onClose();
}}
title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`}
description={
mode === "create"
? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen."
: "Ein neues Passwort beendet alle Sitzungen dieses Kontos."
}
width="md"
>
<form onSubmit={submit} className="space-y-4">
{mode === "create" ? (
<div>
<label className="field-label" htmlFor="admin-username">
Benutzername *
</label>
<input
id="admin-username"
className="field"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoCapitalize="none"
spellCheck={false}
required
/>
{fieldErrors.username ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
) : null}
</div>
) : (
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="admin-display">
Anzeigename
</label>
<input
id="admin-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="admin-email">
E-Mail
</label>
<input
id="admin-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
{fieldErrors.email ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="admin-password">
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
</label>
<input
id="admin-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
required={mode === "create"}
/>
{fieldErrors.password ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
) : null}
</div>
<div>
<span className="field-label">Rolle</span>
<div className="grid grid-cols-2 gap-2">
{(["USER", "ADMIN"] as Role[]).map((value) => (
<button
key={value}
type="button"
onClick={() => setRole(value)}
disabled={isSelf && value === "USER"}
title={
isSelf && value === "USER"
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
: undefined
}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
role === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
{value === "ADMIN" ? "Administrator" : "Benutzer"}
</button>
))}
</div>
</div>
{mode === "edit" ? (
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
<input
type="checkbox"
checked={isActive}
onChange={(event) => setIsActive(event.target.checked)}
disabled={isSelf}
className="h-4 w-4 accent-violet-500"
/>
Konto aktiv
{isSelf ? (
<span className="text-[11px] text-slate-600">
(das eigene Konto laesst sich nicht deaktivieren)
</span>
) : null}
</label>
) : null}
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setLoadedFor(null);
onClose();
}}
disabled={busy}
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Sparkles } from "lucide-react";
/** Zentrierte Karte fuer Einrichtung, Anmeldung und Registrierung. */
export function AuthShell({
appName,
title,
subtitle,
children,
footer,
}: {
appName: string;
title: string;
subtitle?: string;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-10">
<div className="glass fade-up relative overflow-hidden p-7">
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
<div
className="pointer-events-none absolute -right-24 -top-24 h-56 w-56 rounded-full opacity-25 blur-3xl"
style={{ background: "radial-gradient(circle, #8b5cf6, transparent 70%)" }}
aria-hidden="true"
/>
<div className="mb-6 flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400">
<Sparkles className="h-5 w-5 text-white" strokeWidth={2.4} />
</span>
<div>
<p className="text-gradient text-lg font-semibold leading-none">{appName}</p>
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-slate-500">
Vibe Coding Dashboard
</p>
</div>
</div>
<h1 className="text-xl font-semibold text-white">{title}</h1>
{subtitle ? <p className="mt-1.5 text-sm text-slate-400">{subtitle}</p> : null}
<div className="mt-6">{children}</div>
</div>
{footer ? <div className="mt-4 text-center text-sm text-slate-500">{footer}</div> : null}
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startAuthentication } from "@simplewebauthn/browser";
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react";
export function LoginForm({
passkeysAvailable,
registrationOpen,
next,
}: {
passkeysAvailable: boolean;
registrationOpen: boolean;
next: string;
}) {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState<"password" | "passkey" | null>(null);
const [error, setError] = useState<string | null>(null);
function done() {
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
router.replace(next);
router.refresh();
}
async function loginWithPassword(event: React.FormEvent) {
event.preventDefault();
setBusy("password");
setError(null);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
done();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
setBusy(null);
}
}
async function loginWithPasskey() {
setBusy("passkey");
setError(null);
try {
const optionsResponse = await fetch("/api/auth/passkey/login/options", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const optionsPayload = await optionsResponse.json().catch(() => ({}));
if (!optionsResponse.ok) {
throw new Error(optionsPayload.error ?? "Passkey-Anmeldung nicht moeglich.");
}
const assertion = await startAuthentication({
optionsJSON: optionsPayload.options,
});
const verifyResponse = await fetch("/api/auth/passkey/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ response: assertion }),
});
const verifyPayload = await verifyResponse.json().catch(() => ({}));
if (!verifyResponse.ok) {
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
}
done();
} catch (caught) {
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
const message = caught instanceof Error ? caught.message : String(caught);
setError(/abort|NotAllowed/i.test(message) ? null : message);
setBusy(null);
}
}
return (
<div className="space-y-5">
{passkeysAvailable ? (
<>
<button
type="button"
onClick={loginWithPasskey}
disabled={busy !== null}
className="btn btn-ghost w-full py-2.5"
>
{busy === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4 text-violet-300" />
)}
Mit Passkey anmelden
</button>
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wider text-slate-600">
<span className="h-px flex-1 bg-white/8" />
oder
<span className="h-px flex-1 bg-white/8" />
</div>
</>
) : null}
<form onSubmit={loginWithPassword} className="space-y-4">
<div>
<label className="field-label" htmlFor="login-username">
Benutzername
</label>
<input
id="login-username"
className="field"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username webauthn"
autoCapitalize="none"
spellCheck={false}
required
autoFocus
/>
</div>
<div>
<label className="field-label" htmlFor="login-password">
Passwort
</label>
<input
id="login-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
{busy === "password" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
Anmelden
</button>
</form>
{registrationOpen ? (
<p className="text-center text-sm text-slate-500">
Noch kein Konto?{" "}
<Link href="/register" className="text-violet-300 hover:text-violet-200">
Registrieren
</Link>
</p>
) : null}
{!passkeysAvailable ? (
<p className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-[11px] leading-snug text-slate-500">
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Passkeys sind deaktiviert: Sie brauchen einen sicheren Kontext. Dafuer muss
<code className="mx-1 text-violet-300">APP_URL</code>
auf eine https-Adresse oder auf http://localhost zeigen.
</span>
</p>
) : null}
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2, UserPlus } from "lucide-react";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
export function RegisterForm() {
const router = useRouter();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setFieldErrors({});
if (password !== repeat) {
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
return;
}
setBusy(true);
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName, email, password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Registrierung fehlgeschlagen.");
}
router.replace("/");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
setBusy(false);
}
}
return (
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="register-username">
Benutzername *
</label>
<input
id="register-username"
className="field"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
autoFocus
/>
{fieldErrors.username ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="register-display">
Anzeigename
</label>
<input
id="register-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="register-email">
E-Mail
</label>
<input
id="register-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
placeholder="optional"
/>
{fieldErrors.email ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="register-password">
Passwort *
</label>
<input
id="register-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
required
minLength={PASSWORD_MIN_LENGTH}
/>
{fieldErrors.password ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="register-repeat">
Passwort wiederholen *
</label>
<input
id="register-repeat"
className="field"
type="password"
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
autoComplete="new-password"
required
/>
{fieldErrors.repeat ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
Konto anlegen
</button>
<p className="text-center text-sm text-slate-500">
Schon ein Konto?{" "}
<Link href="/login" className="text-violet-300 hover:text-violet-200">
Anmelden
</Link>
</p>
</form>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Rocket, User, Users } from "lucide-react";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn } from "@/lib/utils";
type Mode = "SINGLE_USER" | "MULTI_USER";
const MODES: Array<{ value: Mode; label: string; hint: string; icon: typeof User }> = [
{
value: "SINGLE_USER",
label: "Nur ich",
hint: "Ein Konto, keine Registrierung, keine Benutzerliste.",
icon: User,
},
{
value: "MULTI_USER",
label: "Mehrere Personen",
hint: "Weitere Konten moeglich, jedes mit streng getrennten Daten.",
icon: Users,
},
];
export function SetupForm() {
const router = useRouter();
const [username, setUsername] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [mode, setMode] = useState<Mode>("SINGLE_USER");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setFieldErrors({});
if (password !== repeat) {
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
return;
}
setBusy(true);
try {
const response = await fetch("/api/auth/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName, email, password, mode }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
setFieldErrors(payload.fields ?? {});
throw new Error(payload.error ?? "Einrichtung fehlgeschlagen.");
}
router.replace("/");
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
setBusy(false);
}
}
return (
<form onSubmit={submit} className="space-y-5">
<div>
<span className="field-label">Betriebsart</span>
<div className="grid gap-2 sm:grid-cols-2">
{MODES.map((option) => {
const Icon = option.icon;
const active = mode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={cn(
"rounded-xl border px-3 py-3 text-left transition-all",
active
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
: "border-white/9 hover:border-white/20",
)}
>
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
{option.label}
</span>
<span className="mt-1 block text-[11px] leading-snug text-slate-500">
{option.hint}
</span>
</button>
);
})}
</div>
<p className="mt-1.5 text-[11px] text-slate-500">
Laesst sich spaeter in den Admin-Einstellungen umstellen.
</p>
</div>
<div>
<label className="field-label" htmlFor="setup-username">
Benutzername *
</label>
<input
id="setup-username"
className="field"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
placeholder="florian"
required
autoFocus
/>
{fieldErrors.username ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="setup-display">
Anzeigename
</label>
<input
id="setup-display"
className="field"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Florian"
/>
</div>
<div>
<label className="field-label" htmlFor="setup-email">
E-Mail
</label>
<input
id="setup-email"
className="field"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
placeholder="optional"
/>
{fieldErrors.email ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
) : null}
</div>
</div>
<div>
<label className="field-label" htmlFor="setup-password">
Passwort *
</label>
<input
id="setup-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
required
minLength={PASSWORD_MIN_LENGTH}
/>
<p className="mt-1 text-[11px] text-slate-500">
Mindestens {PASSWORD_MIN_LENGTH} Zeichen. Einen Passkey kannst du danach unter
„Mein Konto“ hinzufuegen.
</p>
{fieldErrors.password ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="setup-repeat">
Passwort wiederholen *
</label>
<input
id="setup-repeat"
className="field"
type="password"
value={repeat}
onChange={(event) => setRepeat(event.target.value)}
autoComplete="new-password"
required
/>
{fieldErrors.repeat ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
) : null}
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Rocket className="h-4 w-4" />}
Einrichten und loslegen
</button>
</form>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect } from "react";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
interface ModalProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: React.ReactNode;
footer?: React.ReactNode;
width?: "md" | "lg" | "xl";
}
export function Modal({
open,
title,
description,
onClose,
children,
footer,
width = "lg",
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previous;
};
}, [open, onClose]);
if (!open) return null;
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]/80 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
"glass fade-up relative z-10 my-auto w-full p-6",
width === "md" && "max-w-lg",
width === "lg" && "max-w-2xl",
width === "xl" && "max-w-4xl",
)}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
{description ? (
<p className="mt-1 text-sm text-slate-400">{description}</p>
) : null}
</div>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-white/8 hover:text-white"
aria-label="Schliessen"
>
<X className="h-4 w-4" />
</button>
</div>
{children}
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Konstanten, die Server und Browser gemeinsam brauchen.
*
* Bewusst in einer eigenen Datei ohne node:crypto-Import: password.ts laesst
* sich in Client-Komponenten nicht einbinden, diese Werte schon.
*/
export const PASSWORD_MIN_LENGTH = 10;
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
+62
View File
@@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
/**
* Zugriffsschutz.
*
* Die Middleware prueft nur, ob ueberhaupt ein Sitzungscookie mitkommt – im
* Edge-Runtime steht keine Datenbank zur Verfuegung. Die eigentliche
* Entscheidung faellt hier, serverseitig, und zwar in jeder Seite und jedem
* API-Endpunkt. Das ist die Sicherheitsgrenze.
*/
export type { SessionUser };
/** Fuer Server Components: leitet auf /login bzw. /setup um. */
export async function requireUserPage(): Promise<SessionUser> {
const user = await getSessionUser();
if (user) return user;
const state = await getInstanceState();
redirect(state.needsSetup ? "/setup" : "/login");
}
/** Wie requireUserPage, verlangt zusaetzlich die Administratorrolle. */
export async function requireAdminPage(): Promise<SessionUser> {
const user = await requireUserPage();
if (user.role !== "ADMIN") redirect("/");
return user;
}
export type ApiAuth =
| { ok: true; user: SessionUser }
| { ok: false; response: NextResponse };
/** Fuer Route Handler: liefert entweder den Benutzer oder eine 401/403-Antwort. */
export async function requireUserApi(): Promise<ApiAuth> {
const user = await getSessionUser();
if (!user) {
return {
ok: false,
response: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }),
};
}
return { ok: true, user };
}
export async function requireAdminApi(): Promise<ApiAuth> {
const auth = await requireUserApi();
if (!auth.ok) return auth;
if (auth.user.role !== "ADMIN") {
return {
ok: false,
response: NextResponse.json(
{ error: "Diese Aktion ist Administratoren vorbehalten." },
{ status: 403 },
),
};
}
return auth;
}
+110
View File
@@ -0,0 +1,110 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
/**
* Passwort-Hashing mit scrypt aus der Node-Standardbibliothek – kein
* zusaetzliches natives Modul noetig, und scrypt ist speicherhart, also
* deutlich unangenehmer fuer GPU-Angriffe als PBKDF2.
*
* Ablageformat: scrypt$<N>$<r>$<p>$<salt>$<hash> (salt/hash base64url)
*/
const scrypt = promisify(scryptCallback) as (
password: string | Buffer,
salt: string | Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
const PARAMS = { N: 32768, r: 8, p: 1 };
const KEY_LENGTH = 64;
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
// dafuer nicht ganz.
const MAX_MEM = 96 * 1024 * 1024;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, {
...PARAMS,
maxmem: MAX_MEM,
});
return [
"scrypt",
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString("base64url"),
derived.toString("base64url"),
].join("$");
}
export async function verifyPassword(
password: string,
stored: string | null | undefined,
): Promise<boolean> {
if (!stored) return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
const salt = Buffer.from(parts[4], "base64url");
const expected = Buffer.from(parts[5], "base64url");
let derived: Buffer;
try {
derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
N,
r,
p,
maxmem: MAX_MEM,
});
} catch {
return false;
}
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected);
}
/**
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
* Antwortzeit ablesen, welche Konten existieren.
*/
export async function fakeVerifyDelay(): Promise<void> {
await scrypt("nebula-dummy-password", "nebula-dummy-salt", KEY_LENGTH, {
...PARAMS,
maxmem: MAX_MEM,
});
}
const OBVIOUS = new Set([
"password", "passwort", "12345678", "123456789", "1234567890",
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
"willkommen", "nebula123", "geheim123",
]);
export { PASSWORD_MIN_LENGTH };
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
export function validatePasswordStrength(password: string): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
}
if (password.length > 200) {
return "Das Passwort darf hoechstens 200 Zeichen haben.";
}
if (OBVIOUS.has(password.toLowerCase())) {
return "Dieses Passwort ist zu naheliegend.";
}
if (/^\d+$/.test(password)) {
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
}
return null;
}
+137
View File
@@ -0,0 +1,137 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextResponse } from "next/server";
import type { User } from "@prisma/client";
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Serverseitige Sitzungen.
*
* Der Cookie enthaelt ein Zufallstoken, die Datenbank nur dessen SHA-256-Hash.
* Wer die Datenbank liest, kann damit keine Sitzung uebernehmen. Abmelden und
* Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird – anders
* als bei einem selbsttragenden Token.
*/
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("base64url");
}
export interface SessionUser {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: User["role"];
hasPassword: boolean;
}
function toSessionUser(user: User): SessionUser {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
hasPassword: Boolean(user.passwordHash),
};
}
export async function createSession(
userId: string,
meta: { userAgent?: string | null; ip?: string | null } = {},
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await prisma.session.create({
data: {
userId,
tokenHash: hashToken(token),
expiresAt,
userAgent: meta.userAgent?.slice(0, 300) ?? null,
ip: meta.ip?.slice(0, 60) ?? null,
},
});
// Gelegenheit nutzen und abgelaufene Sitzungen wegraeumen.
void prisma.session
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
.catch(() => undefined);
return { token, expiresAt };
}
export function setSessionCookie(response: NextResponse, token: string, expiresAt: Date) {
response.cookies.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
expires: expiresAt,
});
}
export function clearSessionCookie(response: NextResponse) {
response.cookies.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
});
}
/**
* Liest die Sitzung aus dem Cookie und prueft sie gegen die Datenbank.
* Gibt null zurueck, wenn kein oder ein ungueltiges Token vorliegt oder das
* Konto deaktiviert wurde.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() <= Date.now()) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null;
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
// Datenbank anfasst.
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
}
return toSessionUser(session.user);
}
export async function destroyCurrentSession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return;
await prisma.session
.deleteMany({ where: { tokenHash: hashToken(token) } })
.catch(() => undefined);
}
/** Meldet ein Konto ueberall ab – nach Passwortwechsel oder Sperrung. */
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
+43
View File
@@ -0,0 +1,43 @@
import type { AppSettings } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
* sie wird beim ersten Zugriff angelegt.
*/
export async function getAppSettings(): Promise<AppSettings> {
return prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", rpName: APP_NAME },
update: {},
});
}
export interface InstanceState {
settings: AppSettings;
userCount: number;
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
needsSetup: boolean;
/** Darf sich jemand selbst registrieren? */
registrationOpen: boolean;
multiUser: boolean;
}
export async function getInstanceState(): Promise<InstanceState> {
const [settings, userCount] = await Promise.all([
getAppSettings(),
prisma.user.count(),
]);
const multiUser = settings.mode === "MULTI_USER";
return {
settings,
userCount,
needsSetup: userCount === 0,
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
registrationOpen: multiUser && settings.allowRegistration,
multiUser,
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Benutzerfelder, die an den Browser gehen duerfen. */
export const userSafeSelect = {
id: true,
username: true,
displayName: true,
email: true,
role: true,
isActive: true,
lastLoginAt: true,
lockedUntil: true,
createdAt: true,
_count: { select: { credentials: true, projects: true, sessions: true } },
} satisfies Prisma.UserSelect;
export type UserListEntry = Prisma.UserGetPayload<{ select: typeof userSafeSelect }>;
/** Sperre nach zu vielen Fehlversuchen. */
const LOCK_THRESHOLD = 8;
const LOCK_MINUTES = 15;
export async function noteFailedLogin(userId: string): Promise<void> {
const user = await prisma.user.update({
where: { id: userId },
data: { failedLoginCount: { increment: 1 } },
select: { failedLoginCount: true },
});
if (user.failedLoginCount >= LOCK_THRESHOLD) {
await prisma.user.update({
where: { id: userId },
data: {
lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000),
failedLoginCount: 0,
},
});
}
}
export async function noteSuccessfulLogin(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date(), failedLoginCount: 0, lockedUntil: null },
});
}
export function lockRemainingSeconds(lockedUntil: Date | null): number {
if (!lockedUntil) return 0;
const remaining = lockedUntil.getTime() - Date.now();
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
}
/**
* Uebernimmt Projekte und Verbindungen ohne Besitzer.
*
* Greift genau einmal: beim Anlegen des ersten Kontos. So gehen Daten, die
* vor der Einfuehrung der Benutzerverwaltung entstanden sind, nicht verloren.
*/
export async function claimOrphanedData(userId: string): Promise<{
projects: number;
connections: number;
}> {
const [projects, connections] = await prisma.$transaction([
prisma.project.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
prisma.gitConnection.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
]);
return { projects: projects.count, connections: connections.count };
}
+258
View File
@@ -0,0 +1,258 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import type { Credential, User } from "@prisma/client";
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Passkeys (WebAuthn).
*
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
*
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
* neu angelegt werden.
*/
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export class WebAuthnUnavailableError extends Error {
constructor() {
super(
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
"https-Adresse (oder http://localhost) setzen.",
);
this.name = "WebAuthnUnavailableError";
}
}
function assertAvailable() {
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
}
async function storeChallenge(
challenge: string,
purpose: "registration" | "authentication",
userId: string | null,
) {
// Abgelaufene Challenges bei der Gelegenheit entfernen.
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await prisma.webAuthnChallenge.create({
data: {
challenge,
purpose,
userId,
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
}
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
async function consumeChallenge(
challenge: string,
purpose: "registration" | "authentication",
): Promise<{ userId: string | null } | null> {
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
if (!stored) return null;
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
if (stored.purpose !== purpose) return null;
if (stored.expiresAt.getTime() <= Date.now()) return null;
return { userId: stored.userId };
}
// ── Registrierung eines neuen Passkeys ───────────────────────
export async function buildRegistrationOptions(
user: Pick<User, "id" | "username" | "displayName">,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
assertAvailable();
const existing = await prisma.credential.findMany({
where: { userId: user.id },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: APP_NAME,
rpID: WEBAUTHN_RP_ID,
userID: new TextEncoder().encode(user.id),
userName: user.username,
userDisplayName: user.displayName ?? user.username,
attestationType: "none",
// Bereits registrierte Passkeys ausschliessen, damit derselbe
// Authenticator nicht doppelt hinterlegt wird.
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId,
transports: credential.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", user.id);
return options;
}
export async function confirmRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string | null,
): Promise<Credential> {
assertAvailable();
const expectedChallenge = response.response.clientDataJSON
? decodeChallenge(response.response.clientDataJSON)
: null;
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "registration");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
if (stored.userId !== userId) {
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
return prisma.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name?.trim() || null,
},
});
}
// ── Anmeldung per Passkey ────────────────────────────────────
/**
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
*/
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
assertAvailable();
const options = await generateAuthenticationOptions({
rpID: WEBAUTHN_RP_ID,
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return options;
}
export interface PasskeyLoginResult {
userId: string;
credentialId: string;
}
export async function confirmAuthentication(
response: AuthenticationResponseJSON,
): Promise<PasskeyLoginResult> {
assertAvailable();
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "authentication");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
const credential = await prisma.credential.findUnique({
where: { credentialId: response.id },
include: { user: true },
});
if (!credential) {
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
}
if (!credential.user.isActive) {
throw new Error("Das Konto ist deaktiviert.");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
credential: {
id: credential.credentialId,
publicKey: new Uint8Array(credential.publicKey),
counter: credential.counter,
transports: credential.transports as AuthenticatorTransportFuture[],
},
});
if (!verification.verified) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
// SimpleWebAuthn wirft in dem Fall bereits oben.
await prisma.credential.update({
where: { id: credential.id },
data: {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
},
});
return { userId: credential.userId, credentialId: credential.credentialId };
}
/**
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
* zu finden und zu entwerten.
*/
function decodeChallenge(clientDataJSON: string): string | null {
try {
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
challenge?: string;
};
return typeof json.challenge === "string" ? json.challenge : null;
} catch {
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Laufzeitkonfiguration.
*
* Bewusst ohne NEXT_PUBLIC_-Variablen: die wuerden beim Build fest in das
* JavaScript-Bundle geschrieben. So laesst sich dasselbe Docker-Image ohne
* Neubau anders benennen und unter einer anderen URL betreiben. Werte, die
* der Browser braucht, reicht das Layout als Props weiter.
*/
function trimmed(value: string | undefined): string | null {
const result = value?.trim();
return result ? result : null;
}
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
/** Kleine Zeile unter dem Namen in der Kopfzeile. */
export const APP_TAGLINE = trimmed(process.env.APP_TAGLINE) ?? "Vibe Coding";
const RAW_APP_URL = trimmed(process.env.APP_URL) ?? "http://localhost:3000";
function parseAppUrl(raw: string): URL {
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`APP_URL muss http oder https sein, ist aber "${url.protocol}"`);
}
return url;
} catch (error) {
throw new Error(
`APP_URL ist keine gueltige URL: "${raw}". Beispiel: https://nebula.example.com` +
` (${error instanceof Error ? error.message : String(error)})`,
);
}
}
const APP_URL_PARSED = parseAppUrl(RAW_APP_URL);
/** Vollstaendige Basis-URL ohne abschliessenden Slash. */
export const APP_URL = APP_URL_PARSED.origin;
/** Origin, den WebAuthn in der Assertion erwartet. */
export const WEBAUTHN_ORIGIN = APP_URL_PARSED.origin;
/**
* Relying-Party-ID fuer WebAuthn: der reine Hostname ohne Port.
* Passkeys sind an diesen Wert gebunden – aendert er sich, sind bestehende
* Passkeys nicht mehr verwendbar.
*/
export const WEBAUTHN_RP_ID = APP_URL_PARSED.hostname;
/**
* WebAuthn verlangt einen sicheren Kontext. Ausnahme: localhost gilt in
* Browsern als sicher, auch ohne TLS.
*/
export const WEBAUTHN_AVAILABLE =
APP_URL_PARSED.protocol === "https:" ||
APP_URL_PARSED.hostname === "localhost" ||
APP_URL_PARSED.hostname === "127.0.0.1";
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
/** Lebensdauer einer Sitzung. */
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session";
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
export function describeConfig(): string {
return [
`name=${APP_NAME}`,
`url=${APP_URL}`,
`rpId=${WEBAUTHN_RP_ID}`,
`passkeys=${WEBAUTHN_AVAILABLE ? "moeglich" : "deaktiviert (kein sicherer Kontext)"}`,
`secureCookies=${COOKIES_SECURE}`,
].join(" ");
}
+79
View File
@@ -0,0 +1,79 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
/**
* Verschluesselung der Git-Access-Tokens.
*
* Format des Chiffrats: v1.<iv>.<authTag>.<ciphertext> (jeweils base64url)
*
* Der Schluessel wird per scrypt aus APP_SECRET abgeleitet. Ohne gesetztes
* APP_SECRET wird ein Entwicklungs-Fallback benutzt – dann warnt die App
* beim Start, weil Tokens sonst faktisch im Klartext liegen.
*/
const DEV_FALLBACK_SECRET = "nebula-dev-secret-please-set-APP_SECRET";
const SALT = "nebula.token.v1";
const VERSION = "v1";
let warned = false;
function secret(): string {
const value = process.env.APP_SECRET;
if (value && value.length >= 16) return value;
if (!warned) {
warned = true;
console.warn(
"[nebula] APP_SECRET ist nicht (ausreichend) gesetzt – es wird ein unsicherer " +
"Entwicklungsschluessel verwendet. Bitte APP_SECRET in der .env setzen.",
);
}
return DEV_FALLBACK_SECRET;
}
function key(): Buffer {
return scryptSync(secret(), SALT, 32);
}
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key(), iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return [
VERSION,
iv.toString("base64url"),
cipher.getAuthTag().toString("base64url"),
encrypted.toString("base64url"),
].join(".");
}
export function decryptSecret(payload: string): string {
const parts = payload.split(".");
if (parts.length !== 4 || parts[0] !== VERSION) {
throw new Error("Ungueltiges Token-Format");
}
const [, ivPart, tagPart, dataPart] = parts;
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(ivPart, "base64url"));
decipher.setAuthTag(Buffer.from(tagPart, "base64url"));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(dataPart, "base64url")),
decipher.final(),
]);
return decrypted.toString("utf8");
}
/** Entschluesselt und schluckt Fehler – z. B. wenn APP_SECRET gewechselt wurde. */
export function tryDecryptSecret(payload: string | null | undefined): string | null {
if (!payload) return null;
try {
return decryptSecret(payload);
} catch {
return null;
}
}
/** Verkuerzte Darstellung fuer die UI: "ghp_…9f3a". */
export function tokenHint(token: string): string {
const trimmed = token.trim();
if (trimmed.length <= 8) return "••••";
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
}
+22
View File
@@ -0,0 +1,22 @@
import { PrismaClient } from "@prisma/client";
/**
* Im Dev-Modus laedt Next die Module bei jedem Hot-Reload neu. Ohne diesen
* Cache wuerde pro Reload eine neue Verbindung zur Datenbank aufgebaut.
*/
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["warn", "error"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+225
View File
@@ -0,0 +1,225 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
CommitInfo,
ConnectionCheck,
GitClient,
ProviderConfig,
ReleaseInfo,
RepoListItem,
RepoRef,
RepoSnapshotData,
TagInfo,
} from "./types";
/**
* Gitea (und kompatible Forks wie Forgejo) – API v1.
* Authentifizierung per "Authorization: token <PAT>".
*/
export class GiteaClient implements GitClient {
readonly provider = "GITEA" as const;
private readonly apiBase: string;
private readonly token: string | null;
constructor(config: ProviderConfig) {
const base = normalizeBaseUrl(config.baseUrl);
this.apiBase = `${base}/api/v1`;
this.token = config.token;
}
private headers(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers.Authorization = `token ${this.token}`;
return headers;
}
async check(): Promise<ConnectionCheck> {
try {
const version = await getJsonSafe<{ version?: string }>(
`${this.apiBase}/version`,
{},
{ headers: this.headers() },
);
if (!this.token) {
return {
ok: true,
message: `Instanz erreichbar${version.version ? ` (Gitea ${version.version})` : ""} – ohne Token nur oeffentliche Repos.`,
user: null,
version: version.version ?? null,
};
}
const user = await getJson<{ login: string; full_name?: string }>(`${this.apiBase}/user`, {
headers: this.headers(),
});
return {
ok: true,
message: `Verbunden als ${user.login}${version.version ? ` (Gitea ${version.version})` : ""}`,
user: user.login,
version: version.version ?? null,
};
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
async listRepos(query?: string): Promise<RepoListItem[]> {
const url = this.token
? `${this.apiBase}/user/repos?limit=100`
: `${this.apiBase}/repos/search?limit=50${query ? `&q=${encodeURIComponent(query)}` : ""}`;
const raw = await getJson<GiteaRepo[] | { data: GiteaRepo[] }>(url, {
headers: this.headers(),
});
const repos = Array.isArray(raw) ? raw : (raw?.data ?? []);
const needle = query?.toLowerCase().trim();
return repos
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
.map((repo) => ({
fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
name: repo.name,
description: repo.description ?? null,
htmlUrl: repo.html_url,
defaultBranch: repo.default_branch ?? null,
visibility: repo.private ? "private" : "public",
updatedAt: toIso(repo.updated_at),
}));
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const headers = this.headers();
const repo = await getJson<GiteaRepo>(base, { headers });
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
getJsonSafe<GiteaBranch[]>(`${base}/branches?limit=50`, [], { headers }),
getJsonSafe<GiteaTag[]>(`${base}/tags?limit=25`, [], { headers }),
getJsonSafe<GiteaCommit[]>(`${base}/commits?limit=15&stat=false`, [], { headers }),
getJsonSafe<GiteaRelease[]>(`${base}/releases?limit=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GiteaPull[]>(`${base}/pulls?state=open&limit=100`, [], { headers }),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name,
sha: branch.commit?.id ?? null,
isDefault: branch.name === repo.default_branch,
protected: Boolean(branch.protected),
lastCommitAt: toIso(branch.commit?.timestamp),
lastCommitMessage: branch.commit?.message?.split("\n")[0] ?? null,
lastCommitAuthor: branch.commit?.author?.name ?? null,
url: `${repo.html_url}/src/branch/${encodeURIComponent(branch.name)}`,
}));
const mappedTags: TagInfo[] = tags.map((tag) => ({
name: tag.name,
sha: tag.commit?.sha ?? tag.id ?? null,
message: tag.message ?? null,
date: toIso(tag.commit?.created),
url: tag.zipball_url ? `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}` : null,
}));
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
sha: commit.sha,
shortSha: shortSha(commit.sha),
message: commit.commit?.message?.split("\n")[0] ?? "",
author: commit.commit?.author?.name ?? commit.author?.login ?? null,
date: toIso(commit.commit?.author?.date ?? commit.created),
url: commit.html_url ?? null,
}));
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
name: release.name || release.tag_name,
tag: release.tag_name,
publishedAt: toIso(release.published_at ?? release.created_at),
url: release.html_url ?? null,
prerelease: Boolean(release.prerelease),
draft: Boolean(release.draft),
}));
return {
fullName: repo.full_name,
description: repo.description ?? null,
defaultBranch: repo.default_branch ?? null,
htmlUrl: repo.html_url,
visibility: repo.private ? "private" : "public",
stars: repo.stars_count ?? 0,
forks: repo.forks_count ?? 0,
openIssues: repo.open_issues_count ?? 0,
openPullRequests: repo.open_pr_counter ?? pulls.length,
sizeKb: repo.size ?? 0,
pushedAt: toIso(repo.updated_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
releases: mappedReleases,
languages: languages ?? {},
};
}
}
// ── API-Typen ──────────────────────────────────────────────────
interface GiteaRepo {
full_name: string;
name: string;
description: string | null;
html_url: string;
default_branch: string | null;
private: boolean;
stars_count: number;
forks_count: number;
open_issues_count: number;
open_pr_counter?: number;
size: number;
updated_at: string | null;
owner?: { login: string };
}
interface GiteaBranch {
name: string;
protected?: boolean;
commit?: {
id: string;
message?: string;
timestamp?: string;
author?: { name?: string };
};
}
interface GiteaTag {
name: string;
id?: string;
message?: string | null;
zipball_url?: string;
commit?: { sha?: string; created?: string };
}
interface GiteaCommit {
sha: string;
html_url?: string;
created?: string;
author?: { login?: string } | null;
commit?: {
message?: string;
author?: { name?: string; date?: string };
};
}
interface GiteaRelease {
name: string | null;
tag_name: string;
published_at: string | null;
created_at: string | null;
html_url: string | null;
prerelease: boolean;
draft: boolean;
}
interface GiteaPull {
id: number;
}
+211
View File
@@ -0,0 +1,211 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
CommitInfo,
ConnectionCheck,
GitClient,
ProviderConfig,
ReleaseInfo,
RepoListItem,
RepoRef,
RepoSnapshotData,
TagInfo,
} from "./types";
import { GitApiError } from "./types";
/**
* GitHub – funktioniert mit github.com und GitHub Enterprise.
* Bei Enterprise liegt die API unter <host>/api/v3.
*/
export class GitHubClient implements GitClient {
readonly provider = "GITHUB" as const;
private readonly apiBase: string;
private readonly token: string | null;
constructor(config: ProviderConfig) {
const base = normalizeBaseUrl(config.baseUrl || "https://github.com");
this.apiBase = /github\.com$/i.test(base) ? "https://api.github.com" : `${base}/api/v3`;
this.token = config.token;
}
private headers(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
return headers;
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
await getJson(`${this.apiBase}/rate_limit`, { headers: this.headers() });
return {
ok: true,
message: "Erreichbar (ohne Token – nur oeffentliche Repos, striktes Rate-Limit).",
user: null,
};
}
const user = await getJson<{ login: string; name?: string }>(`${this.apiBase}/user`, {
headers: this.headers(),
});
return { ok: true, message: `Verbunden als ${user.login}`, user: user.login };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
async listRepos(query?: string): Promise<RepoListItem[]> {
if (!this.token) {
throw new GitApiError("Fuer das Auflisten von Repos wird ein Token benoetigt.", 401, this.apiBase);
}
const repos = await getJson<GitHubRepo[]>(
`${this.apiBase}/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member`,
{ headers: this.headers() },
);
const needle = query?.toLowerCase().trim();
return (repos ?? [])
.filter((repo) => !needle || repo.full_name.toLowerCase().includes(needle))
.map((repo) => ({
fullPath: repo.full_name,
owner: repo.owner?.login ?? repo.full_name.split("/")[0] ?? "",
name: repo.name,
description: repo.description ?? null,
htmlUrl: repo.html_url,
defaultBranch: repo.default_branch ?? null,
visibility: repo.private ? "private" : "public",
updatedAt: toIso(repo.pushed_at ?? repo.updated_at),
}));
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const headers = this.headers();
const repo = await getJson<GitHubRepo>(base, { headers });
const [branches, tags, commits, releases, languages, pulls] = await Promise.all([
getJsonSafe<GitHubBranch[]>(`${base}/branches?per_page=50`, [], { headers }),
getJsonSafe<GitHubTag[]>(`${base}/tags?per_page=25`, [], { headers }),
getJsonSafe<GitHubCommit[]>(`${base}/commits?per_page=15`, [], { headers }),
getJsonSafe<GitHubRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitHubPull[]>(`${base}/pulls?state=open&per_page=100`, [], { headers }),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name,
sha: branch.commit?.sha ?? null,
isDefault: branch.name === repo.default_branch,
protected: Boolean(branch.protected),
lastCommitAt: null,
lastCommitMessage: null,
lastCommitAuthor: null,
url: `${repo.html_url}/tree/${encodeURIComponent(branch.name)}`,
}));
const mappedTags: TagInfo[] = tags.map((tag) => ({
name: tag.name,
sha: tag.commit?.sha ?? null,
message: null,
date: null,
url: `${repo.html_url}/releases/tag/${encodeURIComponent(tag.name)}`,
}));
const mappedCommits: CommitInfo[] = commits.map((entry) => ({
sha: entry.sha,
shortSha: shortSha(entry.sha),
message: entry.commit?.message?.split("\n")[0] ?? "",
author: entry.commit?.author?.name ?? entry.author?.login ?? null,
date: toIso(entry.commit?.author?.date ?? entry.commit?.committer?.date),
url: entry.html_url ?? null,
}));
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
name: release.name || release.tag_name,
tag: release.tag_name,
publishedAt: toIso(release.published_at ?? release.created_at),
url: release.html_url ?? null,
prerelease: Boolean(release.prerelease),
draft: Boolean(release.draft),
}));
// open_issues_count zaehlt bei GitHub PRs mit – hier wieder herausrechnen.
const openPullRequests = pulls.length;
const openIssues = Math.max(0, (repo.open_issues_count ?? 0) - openPullRequests);
return {
fullName: repo.full_name,
description: repo.description ?? null,
defaultBranch: repo.default_branch ?? null,
htmlUrl: repo.html_url,
visibility: repo.private ? "private" : "public",
stars: repo.stargazers_count ?? 0,
forks: repo.forks_count ?? 0,
openIssues,
openPullRequests,
sizeKb: repo.size ?? 0,
pushedAt: toIso(repo.pushed_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
releases: mappedReleases,
languages: languages ?? {},
};
}
}
// ── API-Typen (nur die tatsaechlich genutzten Felder) ──────────
interface GitHubRepo {
full_name: string;
name: string;
description: string | null;
html_url: string;
default_branch: string;
private: boolean;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
size: number;
pushed_at: string | null;
updated_at: string | null;
owner?: { login: string };
}
interface GitHubBranch {
name: string;
protected?: boolean;
commit?: { sha: string };
}
interface GitHubTag {
name: string;
commit?: { sha: string };
}
interface GitHubCommit {
sha: string;
html_url?: string;
author?: { login: string } | null;
commit?: {
message?: string;
author?: { name?: string; date?: string };
committer?: { date?: string };
};
}
interface GitHubRelease {
name: string | null;
tag_name: string;
published_at: string | null;
created_at: string | null;
html_url: string | null;
prerelease: boolean;
draft: boolean;
}
interface GitHubPull {
id: number;
}
+209
View File
@@ -0,0 +1,209 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import type {
BranchInfo,
CommitInfo,
ConnectionCheck,
GitClient,
ProviderConfig,
ReleaseInfo,
RepoListItem,
RepoRef,
RepoSnapshotData,
TagInfo,
} from "./types";
/**
* GitLab – gitlab.com und selbst gehostete Instanzen (API v4).
* Projekte werden ueber den URL-kodierten Pfad "gruppe%2Fprojekt" adressiert,
* daher funktionieren auch verschachtelte Gruppen.
*/
export class GitLabClient implements GitClient {
readonly provider = "GITLAB" as const;
private readonly apiBase: string;
private readonly token: string | null;
constructor(config: ProviderConfig) {
const base = normalizeBaseUrl(config.baseUrl || "https://gitlab.com");
this.apiBase = `${base}/api/v4`;
this.token = config.token;
}
private headers(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.token) headers["PRIVATE-TOKEN"] = this.token;
return headers;
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
await getJson(`${this.apiBase}/projects?per_page=1`, { headers: this.headers() });
return { ok: true, message: "Erreichbar (ohne Token – nur oeffentliche Projekte).", user: null };
}
const user = await getJson<{ username: string; name?: string }>(`${this.apiBase}/user`, {
headers: this.headers(),
});
return { ok: true, message: `Verbunden als ${user.username}`, user: user.username };
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) };
}
}
async listRepos(query?: string): Promise<RepoListItem[]> {
const search = query ? `&search=${encodeURIComponent(query)}` : "";
const url = this.token
? `${this.apiBase}/projects?membership=true&per_page=100&order_by=last_activity_at${search}`
: `${this.apiBase}/projects?per_page=50&order_by=last_activity_at${search}`;
const projects = await getJson<GitLabProject[]>(url, { headers: this.headers() });
return (projects ?? []).map((project) => {
const path = project.path_with_namespace;
const segments = path.split("/");
return {
fullPath: path,
owner: segments.slice(0, -1).join("/"),
name: segments[segments.length - 1] ?? project.name,
description: project.description ?? null,
htmlUrl: project.web_url,
defaultBranch: project.default_branch ?? null,
visibility: project.visibility ?? null,
updatedAt: toIso(project.last_activity_at),
};
});
}
async fetchRepo(ref: RepoRef): Promise<RepoSnapshotData> {
const path = [ref.owner, ref.name].filter(Boolean).join("/");
const encoded = encodeURIComponent(path);
const base = `${this.apiBase}/projects/${encoded}`;
const headers = this.headers();
const project = await getJson<GitLabProject>(`${base}?statistics=true`, { headers });
const [branches, tags, commits, releases, languages, mergeRequests] = await Promise.all([
getJsonSafe<GitLabBranch[]>(`${base}/repository/branches?per_page=50`, [], { headers }),
getJsonSafe<GitLabTag[]>(`${base}/repository/tags?per_page=25`, [], { headers }),
getJsonSafe<GitLabCommit[]>(`${base}/repository/commits?per_page=15`, [], { headers }),
getJsonSafe<GitLabRelease[]>(`${base}/releases?per_page=10`, [], { headers }),
getJsonSafe<Record<string, number>>(`${base}/languages`, {}, { headers }),
getJsonSafe<GitLabMergeRequest[]>(`${base}/merge_requests?state=opened&per_page=100`, [], {
headers,
}),
]);
const mappedBranches: BranchInfo[] = branches.map((branch) => ({
name: branch.name,
sha: branch.commit?.id ?? null,
isDefault: Boolean(branch.default),
protected: Boolean(branch.protected),
lastCommitAt: toIso(branch.commit?.committed_date ?? branch.commit?.created_at),
lastCommitMessage: branch.commit?.title ?? null,
lastCommitAuthor: branch.commit?.author_name ?? null,
url: branch.web_url ?? null,
}));
const mappedTags: TagInfo[] = tags.map((tag) => ({
name: tag.name,
sha: tag.commit?.id ?? null,
message: tag.message ?? null,
date: toIso(tag.commit?.committed_date ?? tag.commit?.created_at),
url: `${project.web_url}/-/tags/${encodeURIComponent(tag.name)}`,
}));
const mappedCommits: CommitInfo[] = commits.map((commit) => ({
sha: commit.id,
shortSha: commit.short_id ?? shortSha(commit.id),
message: commit.title ?? commit.message?.split("\n")[0] ?? "",
author: commit.author_name ?? null,
date: toIso(commit.committed_date ?? commit.created_at),
url: commit.web_url ?? null,
}));
const mappedReleases: ReleaseInfo[] = releases.map((release) => ({
name: release.name || release.tag_name,
tag: release.tag_name,
publishedAt: toIso(release.released_at ?? release.created_at),
url: release._links?.self ?? null,
prerelease: Boolean(release.upcoming_release),
draft: false,
}));
return {
fullName: project.path_with_namespace,
description: project.description ?? null,
defaultBranch: project.default_branch ?? null,
htmlUrl: project.web_url,
visibility: project.visibility ?? null,
stars: project.star_count ?? 0,
forks: project.forks_count ?? 0,
openIssues: project.open_issues_count ?? 0,
openPullRequests: mergeRequests.length,
sizeKb: project.statistics?.repository_size
? Math.round(project.statistics.repository_size / 1024)
: 0,
pushedAt: toIso(project.last_activity_at),
branches: mappedBranches,
tags: mappedTags,
commits: mappedCommits,
releases: mappedReleases,
languages: languages ?? {},
};
}
}
// ── API-Typen ──────────────────────────────────────────────────
interface GitLabProject {
id: number;
name: string;
path_with_namespace: string;
description: string | null;
web_url: string;
default_branch: string | null;
visibility: string | null;
star_count: number;
forks_count: number;
open_issues_count: number;
last_activity_at: string | null;
statistics?: { repository_size?: number };
}
interface GitLabCommitStub {
id: string;
short_id?: string;
title?: string;
message?: string;
author_name?: string;
committed_date?: string;
created_at?: string;
web_url?: string;
}
interface GitLabBranch {
name: string;
default?: boolean;
protected?: boolean;
web_url?: string;
commit?: GitLabCommitStub;
}
interface GitLabTag {
name: string;
message: string | null;
commit?: GitLabCommitStub;
}
type GitLabCommit = GitLabCommitStub;
interface GitLabRelease {
name: string | null;
tag_name: string;
released_at: string | null;
created_at: string | null;
upcoming_release?: boolean;
_links?: { self?: string };
}
interface GitLabMergeRequest {
id: number;
}
+123
View File
@@ -0,0 +1,123 @@
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { GitApiError } from "./types";
const DEFAULT_TIMEOUT = Number(process.env.GIT_API_TIMEOUT_MS ?? 15000);
export interface FetchOptions {
headers?: Record<string, string>;
timeoutMs?: number;
/** Wenn true, liefert die Funktion bei 404 null statt zu werfen. */
allow404?: boolean;
}
/** Entfernt einen abschliessenden Slash, damit URLs sauber zusammengesetzt werden. */
export function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
function friendlyMessage(status: number, url: string, body: string): string {
switch (status) {
case 401:
return "Nicht autorisiert (401) – Token fehlt, ist abgelaufen oder ungueltig.";
case 403:
return body.toLowerCase().includes("rate limit")
? "Rate-Limit erreicht (403) – bitte spaeter erneut versuchen oder Token hinterlegen."
: "Zugriff verweigert (403) – Token hat nicht die noetigen Scopes.";
case 404:
return "Nicht gefunden (404) – Repo-Pfad pruefen oder Token hat keinen Lesezugriff.";
case 429:
return "Zu viele Anfragen (429) – Rate-Limit des Git-Systems.";
default:
if (status >= 500) return `Serverfehler ${status} beim Git-System.`;
return `Anfrage fehlgeschlagen (${status}) auf ${url}`;
}
}
/** GET mit Timeout, JSON-Parsing und sprechenden Fehlern. */
export async function getJson<T>(
url: string,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
// Vor jedem ausgehenden Aufruf pruefen, wohin er geht. Die Basis-URL
// stammt aus einem Formular, also aus Benutzereingabe.
await assertAllowedUrl(url);
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
signal: controller.signal,
cache: "no-store",
redirect: "follow",
});
// Ein Redirect koennte auf eine interne Adresse zeigen (etwa bei einem
// umbenannten GitHub-Repo, aber eben auch boesartig). Deshalb das
// tatsaechliche Ziel nachtraeglich noch einmal pruefen.
if (response.redirected && response.url && response.url !== url) {
await assertAllowedUrl(response.url);
}
if (response.status === 404 && options.allow404) {
return null as T;
}
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, body), response.status, url);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
export async function getJsonSafe<T>(
url: string,
fallback: T,
options: FetchOptions = {},
): Promise<T> {
try {
const result = await getJson<T>(url, options);
return (result ?? fallback) as T;
} catch {
return fallback;
}
}
export function toIso(value: unknown): string | null {
if (!value) return null;
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
export function shortSha(sha: string | null | undefined): string {
return (sha ?? "").slice(0, 7);
}
+105
View File
@@ -0,0 +1,105 @@
import type { GitConnection } from "@prisma/client";
import { tryDecryptSecret } from "@/lib/crypto";
import { GiteaClient } from "./gitea";
import { GitHubClient } from "./github";
import { GitLabClient } from "./gitlab";
import { guessProvider } from "./meta";
import type { GitClient, ProviderConfig, ProviderKind, RepoRef } from "./types";
export * from "./types";
export { GiteaClient, GitHubClient, GitLabClient };
// PROVIDER_META liegt in ./meta, damit Client-Komponenten es ohne
// node:crypto-Abhaengigkeit importieren koennen.
export { PROVIDER_META, guessProvider } from "./meta";
export function createClient(config: ProviderConfig): GitClient {
switch (config.provider) {
case "GITHUB":
return new GitHubClient(config);
case "GITLAB":
return new GitLabClient(config);
case "GITEA":
return new GiteaClient(config);
default: {
const exhaustive: never = config.provider;
throw new Error(`Unbekannter Provider: ${String(exhaustive)}`);
}
}
}
/** Baut einen Client aus einer gespeicherten Verbindung (Token wird entschluesselt). */
export function clientForConnection(connection: GitConnection): GitClient {
return createClient({
provider: connection.provider,
baseUrl: connection.baseUrl,
token: tryDecryptSecret(connection.tokenCipher),
});
}
export interface ParsedRepoUrl {
host: string;
baseUrl: string;
owner: string;
name: string;
/** Aus dem Host geratener Provider – vom Nutzer ueberschreibbar. */
guessedProvider: ProviderKind;
}
/**
* Zerlegt eine Repository-URL. Unterstuetzt:
* https://github.com/user/repo(.git)
* git@gitlab.com:gruppe/subgruppe/repo.git
* https://git.firma.de/team/repo
*/
export function parseRepoUrl(input: string): ParsedRepoUrl | null {
const raw = input.trim();
if (!raw) return null;
let host = "";
let path = "";
let protocol = "https";
const sshMatch = raw.match(/^(?:ssh:\/\/)?(?:[\w.-]+@)([^/:]+)[:/](.+)$/);
if (sshMatch && !raw.startsWith("http")) {
host = sshMatch[1];
path = sshMatch[2];
} else {
try {
const url = new URL(raw.includes("://") ? raw : `https://${raw}`);
host = url.host;
path = url.pathname;
protocol = url.protocol.replace(":", "") || "https";
} catch {
return null;
}
}
const segments = path
.replace(/\.git$/i, "")
.split("/")
.filter(Boolean)
// GitLab-Weboberflaeche haengt bei Unterseiten "/-/tree/main" an.
.reduce<string[]>((acc, segment) => {
if (segment === "-") return acc.length ? [...acc, "__STOP__"] : acc;
if (acc.includes("__STOP__")) return acc;
return [...acc, segment];
}, [])
.filter((segment) => segment !== "__STOP__");
if (segments.length < 2) return null;
const name = segments[segments.length - 1];
const owner = segments.slice(0, -1).join("/");
return {
host,
baseUrl: `${protocol}://${host}`,
owner,
name,
guessedProvider: guessProvider(host),
};
}
export function repoRefFrom(owner: string, name: string): RepoRef {
return { owner, name };
}
+37
View File
@@ -0,0 +1,37 @@
import type { ProviderKind } from "./types";
/**
* Reine Metadaten zu den Git-Systemen – bewusst frei von Node-Imports,
* damit dieses Modul auch in Client-Komponenten benutzt werden kann.
*/
export const PROVIDER_META: Record<
ProviderKind,
{ label: string; defaultBaseUrl: string; tokenHelp: string; docsUrl: string }
> = {
GITHUB: {
label: "GitHub",
defaultBaseUrl: "https://github.com",
tokenHelp:
"Fine-grained Token mit Lesezugriff auf Contents, Metadata, Issues und Pull Requests.",
docsUrl: "https://github.com/settings/tokens",
},
GITLAB: {
label: "GitLab",
defaultBaseUrl: "https://gitlab.com",
tokenHelp: "Personal Access Token mit Scope 'read_api' (bzw. 'read_repository').",
docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
},
GITEA: {
label: "Gitea / Forgejo",
defaultBaseUrl: "https://git.example.com",
tokenHelp: "Access Token mit Leserechten auf repository, issue und user.",
docsUrl: "https://docs.gitea.com/development/api-usage",
},
};
export function guessProvider(host: string): ProviderKind {
const lower = host.toLowerCase();
if (lower.includes("github")) return "GITHUB";
if (lower.includes("gitlab")) return "GITLAB";
return "GITEA";
}
+117
View File
@@ -0,0 +1,117 @@
import type { GitProvider } from "@prisma/client";
export type ProviderKind = GitProvider;
/** Zeiger auf ein konkretes Repository. */
export interface RepoRef {
/** Owner / Namespace / Gruppe – bei GitLab auch verschachtelt ("gruppe/sub"). */
owner: string;
/** Repository-Name ohne Namespace. */
name: string;
}
export interface BranchInfo {
name: string;
sha: string | null;
isDefault: boolean;
protected: boolean;
lastCommitAt: string | null;
lastCommitMessage: string | null;
lastCommitAuthor: string | null;
url: string | null;
}
export interface TagInfo {
name: string;
sha: string | null;
message: string | null;
date: string | null;
url: string | null;
}
export interface CommitInfo {
sha: string;
shortSha: string;
message: string;
author: string | null;
date: string | null;
url: string | null;
}
export interface ReleaseInfo {
name: string;
tag: string;
publishedAt: string | null;
url: string | null;
prerelease: boolean;
draft: boolean;
}
export interface RepoOverview {
fullName: string;
description: string | null;
defaultBranch: string | null;
htmlUrl: string | null;
visibility: string | null;
stars: number;
forks: number;
openIssues: number;
openPullRequests: number;
sizeKb: number;
pushedAt: string | null;
}
export interface RepoSnapshotData extends RepoOverview {
branches: BranchInfo[];
tags: TagInfo[];
commits: CommitInfo[];
releases: ReleaseInfo[];
languages: Record<string, number>;
}
export interface RepoListItem {
fullPath: string;
owner: string;
name: string;
description: string | null;
htmlUrl: string;
defaultBranch: string | null;
visibility: string | null;
updatedAt: string | null;
}
export interface ConnectionCheck {
ok: boolean;
message: string;
user?: string | null;
version?: string | null;
}
export interface ProviderConfig {
provider: ProviderKind;
baseUrl: string;
token: string | null;
}
export interface GitClient {
readonly provider: ProviderKind;
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
check(): Promise<ConnectionCheck>;
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
fetchRepo(ref: RepoRef): Promise<RepoSnapshotData>;
}
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
export class GitApiError extends Error {
readonly status: number;
readonly url: string;
constructor(message: string, status: number, url: string) {
super(message);
this.name = "GitApiError";
this.status = status;
this.url = url;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { slugify } from "@/lib/utils";
/**
* Erzeugt einen je Besitzer eindeutigen Slug, notfalls mit angehaengtem
* Zaehler. Zwei Benutzer duerfen also beide ein Projekt "dashboard" haben.
*/
export async function uniqueSlug(
name: string,
ownerId: string,
ignoreId?: string,
): Promise<string> {
const base = slugify(name) || "projekt";
let candidate = base;
let counter = 2;
// In der Praxis endet die Schleife nach wenigen Runden.
for (let attempt = 0; attempt < 50; attempt += 1) {
const existing = await prisma.project.findFirst({
where: { slug: candidate, ownerId },
select: { id: true },
});
if (!existing || existing.id === ignoreId) return candidate;
candidate = `${base}-${counter}`;
counter += 1;
}
return `${base}-${Date.now()}`;
}
/**
* Verbindungsfelder, die an den Browser gehen duerfen.
* tokenCipher ist bewusst nicht dabei.
*/
export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>()({
id: true,
name: true,
provider: true,
baseUrl: true,
username: true,
tokenHint: true,
lastCheckedAt: true,
lastCheckOk: true,
lastCheckMessage: true,
createdAt: true,
updatedAt: true,
});
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect },
snapshot: true,
_count: { select: { notes: true } },
});
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect },
snapshot: true,
notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
},
_count: { select: { notes: true } },
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Schlanker Ratenbegrenzer im Arbeitsspeicher.
*
* Nebula ist eine Einzelinstanz ohne geteilten Cache – ein Map genuegt.
* Der Zaehler ueberlebt keinen Neustart, was fuer den Zweck (Bremse gegen
* Passwortraten, Portscans ueber die Git-Endpunkte und das Verheizen fremder
* API-Kontingente) ausreicht.
*/
interface Bucket {
count: number;
resetAt: number;
}
const buckets = new Map<string, Bucket>();
/** Verhindert, dass die Map bei vielen unterschiedlichen Schluesseln waechst. */
function sweep(now: number) {
if (buckets.size < 5000) return;
for (const [key, bucket] of buckets) {
if (bucket.resetAt <= now) buckets.delete(key);
}
}
export interface RateLimitResult {
ok: boolean;
remaining: number;
/** Sekunden bis zum naechsten erlaubten Versuch. */
retryAfter: number;
}
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
const now = Date.now();
sweep(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { ok: true, remaining: limit - 1, retryAfter: 0 };
}
bucket.count += 1;
if (bucket.count > limit) {
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
};
}
return { ok: true, remaining: limit - bucket.count, retryAfter: 0 };
}
/** Nach erfolgreicher Anmeldung die Bremse fuer diesen Schluessel loesen. */
export function resetRateLimit(key: string) {
buckets.delete(key);
}
/** Vorkonfigurierte Kontingente je Anwendungsfall. */
export const LIMITS = {
/** Anmeldeversuche je IP – die eigentliche Bremse gegen Durchprobieren */
login: { limit: 10, windowMs: 5 * 60 * 1000 },
/**
* Anmeldeversuche je Benutzername. Bewusst grosszuegiger als die
* Kontosperre (8 Fehlversuche), damit diese zuerst greift: der Benutzer
* bekommt dann die verstaendliche Meldung "Konto gesperrt" statt eines
* nackten 429. Ausserdem liesse sich ein Konto sonst schon mit fuenf
* falschen Versuchen von aussen blockieren.
*/
loginUser: { limit: 20, windowMs: 15 * 60 * 1000 },
/** Ausgehende Git-Abfragen (Sync, Verbindungstest, Repo-Liste) */
outbound: { limit: 30, windowMs: 60 * 1000 },
/** Schreibende Zugriffe allgemein */
mutation: { limit: 240, windowMs: 60 * 1000 },
/** WebAuthn-Optionen anfordern */
webauthn: { limit: 30, windowMs: 5 * 60 * 1000 },
} as const;
+77
View File
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
/** Groesste akzeptierte JSON-Nutzlast. */
const MAX_BODY_BYTES = 256 * 1024;
export function jsonError(
message: string,
status: number,
extra?: Record<string, unknown>,
): NextResponse {
return NextResponse.json({ error: message, ...extra }, { status });
}
export function tooManyRequests(retryAfter: number): NextResponse {
return NextResponse.json(
{ error: `Zu viele Anfragen. Bitte in ${retryAfter} Sekunden erneut versuchen.` },
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
);
}
export type BodyResult<T> =
| { ok: true; data: T }
| { ok: false; response: NextResponse };
/**
* Liest einen JSON-Body und erzwingt dabei drei Dinge:
*
* 1. Content-Type application/json. Das ist zugleich CSRF-Schutz in der
* Tiefe: ein browserseitiger Cross-Site-Angriff kommt ohne Preflight nur
* mit text/plain, multipart/form-data oder urlencoded durch.
* 2. Eine Obergrenze fuer die Groesse, damit ein grosser Body nicht Speicher
* bindet.
* 3. Sauberes JSON – Parserfehler werden zu 400 statt zu einer Ausnahme.
*/
export async function readJsonBody<T = unknown>(
request: Request,
): Promise<BodyResult<T>> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return {
ok: false,
response: jsonError("Content-Type muss application/json sein.", 415),
};
}
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
let raw: string;
try {
raw = await request.text();
} catch {
return { ok: false, response: jsonError("Anfrage konnte nicht gelesen werden.", 400) };
}
if (raw.length > MAX_BODY_BYTES) {
return { ok: false, response: jsonError("Anfrage zu gross.", 413) };
}
try {
return { ok: true, data: JSON.parse(raw) as T };
} catch {
return { ok: false, response: jsonError("Ungueltiger JSON-Body.", 400) };
}
}
/** Beste verfuegbare Naeherung der Client-Adresse – nur fuer Ratenbegrenzung. */
export function clientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
return request.headers.get("x-real-ip")?.trim() || "unbekannt";
}
+172
View File
@@ -0,0 +1,172 @@
import { isIP } from "node:net";
import { lookup } from "node:dns/promises";
/**
* Schutz vor Server Side Request Forgery.
*
* Die Basis-URL einer Git-Verbindung kommt aus einem Formular. Ohne Pruefung
* koennte darueber jede vom Server erreichbare Adresse abgefragt werden –
* Cloud-Metadaten (169.254.169.254), lokal gebundene Dienste oder ein
* Portscan im Heimnetz.
*
* Voreinstellung:
* verboten – Loopback, Link-Local (inkl. Metadaten), unspezifiziert,
* Multicast, CGNAT
* erlaubt – privates LAN (10/8, 172.16/12, 192.168/16, fc00::/7),
* weil eine selbst gehostete Gitea-Instanz genau dort steht
*
* Ueber Umgebungsvariablen anpassbar:
* GIT_ALLOW_LOOPBACK=true Gitea laeuft auf demselben Rechner
* GIT_BLOCK_PRIVATE=true kein Zugriff ins private Netz
* GIT_ALLOWED_HOSTS=a,b,c strengster Modus: nur diese Hosts
*/
export class BlockedHostError extends Error {
constructor(message: string) {
super(message);
this.name = "BlockedHostError";
}
}
function envFlag(name: string): boolean {
return /^(1|true|yes|on)$/i.test(process.env[name]?.trim() ?? "");
}
function allowlist(): string[] {
return (process.env.GIT_ALLOWED_HOSTS ?? "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
}
function ipv4Parts(address: string): number[] | null {
const parts = address.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part))) return null;
return parts;
}
type Verdict = { blocked: true; reason: string } | { blocked: false };
function classifyIpv4(address: string): Verdict {
const parts = ipv4Parts(address);
if (!parts) return { blocked: true, reason: "unlesbare IPv4-Adresse" };
const [a, b] = parts;
if (a === 0) return { blocked: true, reason: "unspezifizierte Adresse (0.0.0.0/8)" };
if (a === 127) {
return envFlag("GIT_ALLOW_LOOPBACK")
? { blocked: false }
: { blocked: true, reason: "Loopback (127.0.0.0/8) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
}
if (a === 169 && b === 254) {
return { blocked: true, reason: "Link-Local/Metadaten-Endpunkt (169.254.0.0/16)" };
}
if (a >= 224) return { blocked: true, reason: "Multicast oder reservierter Bereich" };
if (a === 100 && b >= 64 && b <= 127) {
return { blocked: true, reason: "Carrier-Grade-NAT (100.64.0.0/10)" };
}
const isPrivate =
a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
if (isPrivate && envFlag("GIT_BLOCK_PRIVATE")) {
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
}
return { blocked: false };
}
function classifyIpv6(address: string): Verdict {
const value = address.toLowerCase().replace(/^\[|\]$/g, "");
// IPv4-gemappte Adressen wie ::ffff:127.0.0.1 nach IPv4 weiterreichen.
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return classifyIpv4(mapped[1]);
if (value === "::" ) return { blocked: true, reason: "unspezifizierte Adresse (::)" };
if (value === "::1") {
return envFlag("GIT_ALLOW_LOOPBACK")
? { blocked: false }
: { blocked: true, reason: "Loopback (::1) – mit GIT_ALLOW_LOOPBACK=true erlauben" };
}
if (value.startsWith("fe80")) return { blocked: true, reason: "Link-Local (fe80::/10)" };
if (value.startsWith("ff")) return { blocked: true, reason: "Multicast (ff00::/8)" };
const isUniqueLocal = value.startsWith("fc") || value.startsWith("fd");
if (isUniqueLocal && envFlag("GIT_BLOCK_PRIVATE")) {
return { blocked: true, reason: "privates Netz ist per GIT_BLOCK_PRIVATE gesperrt" };
}
return { blocked: false };
}
function classifyAddress(address: string): Verdict {
const version = isIP(address);
if (version === 4) return classifyIpv4(address);
if (version === 6) return classifyIpv6(address);
return { blocked: true, reason: `keine gueltige IP-Adresse: ${address}` };
}
/**
* Prueft eine URL, bevor der Server sie abruft. Wirft BlockedHostError,
* wenn das Ziel nicht erlaubt ist.
*/
export async function assertAllowedUrl(rawUrl: string): Promise<URL> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new BlockedHostError(`Ungueltige URL: ${rawUrl}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new BlockedHostError(`Nur http und https sind erlaubt, nicht ${url.protocol}`);
}
if (url.username || url.password) {
throw new BlockedHostError("Zugangsdaten in der URL sind nicht erlaubt.");
}
const host = url.hostname.toLowerCase();
const allowed = allowlist();
if (allowed.length > 0) {
if (!allowed.includes(host)) {
throw new BlockedHostError(
`Host "${host}" steht nicht in GIT_ALLOWED_HOSTS.`,
);
}
// Eine ausdrueckliche Freigabe schlaegt die Bereichspruefung.
return url;
}
// IP-Literal direkt pruefen, Namen erst aufloesen.
if (isIP(host)) {
const verdict = classifyAddress(host);
if (verdict.blocked) {
throw new BlockedHostError(`Zugriff auf ${host} blockiert: ${verdict.reason}`);
}
return url;
}
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(host, { all: true });
} catch {
throw new BlockedHostError(`Hostname "${host}" konnte nicht aufgeloest werden.`);
}
if (addresses.length === 0) {
throw new BlockedHostError(`Hostname "${host}" liefert keine Adresse.`);
}
// Jede Antwort muss zulaessig sein, sonst greift DNS-Rebinding ueber einen
// zweiten A-Record.
for (const { address } of addresses) {
const verdict = classifyAddress(address);
if (verdict.blocked) {
throw new BlockedHostError(
`Zugriff auf ${host} (${address}) blockiert: ${verdict.reason}`,
);
}
}
return url;
}
+98
View File
@@ -0,0 +1,98 @@
import type { ProjectStatus } from "@prisma/client";
export type StatusMeta = {
value: ProjectStatus;
label: string;
short: string;
/** Tailwind-Klassen fuer Pills/Badges */
className: string;
/** Reine Farbe fuer Punkte, Balken und Glows */
color: string;
description: string;
};
export const STATUS_ORDER: ProjectStatus[] = [
"IDEA",
"PLANNING",
"OPEN",
"IN_DEVELOPMENT",
"DONE",
"ARCHIVED",
];
export const STATUS_META: Record<ProjectStatus, StatusMeta> = {
IDEA: {
value: "IDEA",
label: "Idee",
short: "Idee",
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
color: "#22d3ee",
description: "Noch ein Gedanke – nichts gebaut, nichts entschieden.",
},
PLANNING: {
value: "PLANNING",
label: "In Planung",
short: "Planung",
className: "bg-blue-500/12 text-blue-200 ring-blue-400/30",
color: "#60a5fa",
description: "Scope, Stack und Schnitt werden gerade festgelegt.",
},
OPEN: {
value: "OPEN",
label: "Offen",
short: "Offen",
className: "bg-amber-500/12 text-amber-200 ring-amber-400/30",
color: "#fbbf24",
description: "Startklar, aber aktuell liegt es still.",
},
IN_DEVELOPMENT: {
value: "IN_DEVELOPMENT",
label: "In Entwicklung",
short: "Aktiv",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
color: "#a78bfa",
description: "Wird gerade aktiv gebaut.",
},
DONE: {
value: "DONE",
label: "Fertig",
short: "Fertig",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
color: "#34d399",
description: "Ausgeliefert und abgeschlossen.",
},
ARCHIVED: {
value: "ARCHIVED",
label: "Archiviert",
short: "Archiv",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
color: "#94a3b8",
description: "Ruht im Archiv – bleibt erhalten, stoert aber nicht mehr.",
},
};
export const PRIORITY_META: Record<number, { label: string; color: string }> = {
1: { label: "Niedrig", color: "#64748b" },
2: { label: "Normal", color: "#38bdf8" },
3: { label: "Hoch", color: "#fb923c" },
4: { label: "Kritisch", color: "#f43f5e" },
};
export type AccentKey = "violet" | "cyan" | "emerald" | "amber" | "rose" | "blue";
export const ACCENTS: Record<AccentKey, { label: string; from: string; to: string }> = {
violet: { label: "Violett", from: "#8b5cf6", to: "#d946ef" },
cyan: { label: "Cyan", from: "#06b6d4", to: "#3b82f6" },
emerald: { label: "Smaragd", from: "#10b981", to: "#84cc16" },
amber: { label: "Bernstein", from: "#f59e0b", to: "#f43f5e" },
rose: { label: "Rose", from: "#f43f5e", to: "#8b5cf6" },
blue: { label: "Blau", from: "#3b82f6", to: "#6366f1" },
};
export function accentOf(key: string | null | undefined) {
return ACCENTS[(key ?? "violet") as AccentKey] ?? ACCENTS.violet;
}
export function statusOf(status: ProjectStatus | string): StatusMeta {
return STATUS_META[status as ProjectStatus] ?? STATUS_META.IDEA;
}
+135
View File
@@ -0,0 +1,135 @@
import type { Project } from "@prisma/client";
import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult {
ok: boolean;
message: string;
fetchedAt?: string;
}
/**
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
*/
export async function syncProject(projectId: string): Promise<SyncResult> {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { connection: true },
});
if (!project) {
return { ok: false, message: "Projekt nicht gefunden." };
}
const ref = resolveRepoRef(project);
if (!ref) {
return {
ok: false,
message: "Kein Repository hinterlegt – bitte Repo-URL oder Owner/Name setzen.",
};
}
let client;
if (project.connection) {
client = clientForConnection(project.connection);
} else {
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
const parsed = project.repoUrl ? parseRepoUrl(project.repoUrl) : null;
if (!parsed) {
return {
ok: false,
message: "Keine Git-Verbindung ausgewaehlt und die Repo-URL ist nicht auswertbar.",
};
}
client = createClient({
provider: parsed.guessedProvider,
baseUrl: parsed.baseUrl,
token: null,
});
}
try {
const data = await client.fetchRepo(ref);
await persistSnapshot(project.id, data);
return {
ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
fetchedAt: new Date().toISOString(),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await prisma.project.update({
where: { id: project.id },
data: { syncError: message, lastSyncedAt: new Date() },
});
return { ok: false, message };
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef(project: Project) {
if (project.repoOwner && project.repoName) {
return { owner: project.repoOwner, name: project.repoName };
}
if (project.repoUrl) {
const parsed = parseRepoUrl(project.repoUrl);
if (parsed) return { owner: parsed.owner, name: parsed.name };
}
return null;
}
async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
const latestCommit = data.commits[0] ?? null;
const latestRelease =
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
const latestTag = data.tags[0]?.name ?? latestRelease?.tag ?? null;
const payload = {
fullName: data.fullName,
description: data.description,
defaultBranch: data.defaultBranch,
htmlUrl: data.htmlUrl,
visibility: data.visibility,
stars: data.stars,
forks: data.forks,
openIssues: data.openIssues,
openPullRequests: data.openPullRequests,
sizeKb: data.sizeKb,
lastCommitSha: latestCommit?.sha ?? null,
lastCommitMessage: latestCommit?.message ?? null,
lastCommitAuthor: latestCommit?.author ?? null,
lastCommitAt: latestCommit?.date ? new Date(latestCommit.date) : null,
latestTag,
latestRelease: latestRelease?.name ?? null,
latestReleaseAt: latestRelease?.publishedAt ? new Date(latestRelease.publishedAt) : null,
branchCount: data.branches.length,
tagCount: data.tags.length,
branches: data.branches as unknown as object,
tags: data.tags as unknown as object,
commits: data.commits as unknown as object,
releases: data.releases as unknown as object,
languages: data.languages as unknown as object,
pushedAt: data.pushedAt ? new Date(data.pushedAt) : null,
fetchedAt: new Date(),
};
await prisma.$transaction([
prisma.repoSnapshot.upsert({
where: { projectId },
create: { projectId, ...payload },
update: payload,
}),
prisma.project.update({
where: { id: projectId },
data: {
lastSyncedAt: new Date(),
syncError: null,
defaultBranch: data.defaultBranch,
repoUrl: data.htmlUrl ?? undefined,
},
}),
]);
}
+96
View File
@@ -0,0 +1,96 @@
/** Kleine Helfer ohne Abhaengigkeiten. */
export function cn(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
export function slugify(input: string): string {
return input
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "Europe/Berlin",
});
const DATETIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
export function formatDate(value: Date | string | null | undefined): string {
if (!value) return "–";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "–";
return DATE_FORMAT.format(date);
}
export function formatDateTime(value: Date | string | null | undefined): string {
if (!value) return "–";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "–";
return DATETIME_FORMAT.format(date);
}
/**
* "vor 3 Tagen". Bewusst grob gerundet – kleine Abweichungen zwischen
* Server-Render und Hydration fallen damit nicht auf.
*/
export function formatRelative(value: Date | string | null | undefined): string {
if (!value) return "nie";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "nie";
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return "gerade eben";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `vor ${minutes} Min.`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `vor ${hours} Std.`;
const days = Math.round(hours / 24);
if (days < 31) return `vor ${days} ${days === 1 ? "Tag" : "Tagen"}`;
const months = Math.round(days / 30);
if (months < 12) return `vor ${months} Mon.`;
return `vor ${Math.round(months / 12)} J.`;
}
export function truncate(value: string, max = 120): string {
if (value.length <= max) return value;
return `${value.slice(0, max - 1).trimEnd()}…`;
}
export function firstLine(value: string | null | undefined): string {
if (!value) return "";
return value.split("\n")[0] ?? "";
}
export function parseTags(input: string): string[] {
return Array.from(
new Set(
input
.split(",")
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean),
),
).slice(0, 12);
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat("de-DE").format(value);
}
+146
View File
@@ -0,0 +1,146 @@
import { z } from "zod";
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const;
const emptyToNull = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? null : value;
export const projectCreateSchema = z.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(120),
summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
description: z.preprocess(emptyToNull, z.string().trim().max(20000).nullable().optional()),
status: z.enum(STATUS).default("IDEA"),
priority: z.coerce.number().int().min(1).max(4).default(2),
progress: z.coerce.number().int().min(0).max(100).default(0),
accent: z.enum(ACCENTS).default("violet"),
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
favorite: z.boolean().default(false),
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
});
export const projectUpdateSchema = projectCreateSchema.partial();
export const noteCreateSchema = z.object({
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
pinned: z.boolean().default(false),
});
export const noteUpdateSchema = noteCreateSchema.partial();
export const connectionCreateSchema = z.object({
name: z.string().trim().min(1, "Name darf nicht leer sein").max(80),
provider: z.enum(PROVIDERS),
baseUrl: z
.string()
.trim()
.min(1, "Basis-URL fehlt")
.max(300)
.refine((value) => /^https?:\/\//i.test(value), "URL muss mit http:// oder https:// beginnen"),
username: z.preprocess(emptyToNull, z.string().trim().max(120).nullable().optional()),
token: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
});
export const connectionUpdateSchema = connectionCreateSchema.partial();
// ── Benutzer, Anmeldung, Verwaltung ──────────────────────────
/** Benutzernamen werden immer kleingeschrieben gespeichert und verglichen. */
export const usernameSchema = z
.string()
.trim()
.toLowerCase()
.min(3, "Mindestens 3 Zeichen")
.max(32, "Hoechstens 32 Zeichen")
.regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich",
);
const passwordField = z.string().min(1, "Passwort fehlt").max(200);
const emailField = z.preprocess(
emptyToNull,
z.string().trim().toLowerCase().email("Keine gueltige E-Mail-Adresse").nullable().optional(),
);
const displayNameField = z.preprocess(
emptyToNull,
z.string().trim().max(80).nullable().optional(),
);
export const setupSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
/** Betriebsart, die beim Einrichten gewaehlt wurde. */
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).default("SINGLE_USER"),
});
export const loginSchema = z.object({
username: z.string().trim().toLowerCase().min(1, "Benutzername fehlt").max(64),
password: passwordField,
});
export const registerSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
});
export const changePasswordSchema = z.object({
/** Entfaellt bei Konten, die bisher nur einen Passkey haben. */
currentPassword: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
newPassword: passwordField,
});
export const profileUpdateSchema = z.object({
displayName: displayNameField,
email: emailField,
});
export const adminSettingsSchema = z.object({
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
allowRegistration: z.boolean().optional(),
});
export const adminUserCreateSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
role: z.enum(["ADMIN", "USER"]).default("USER"),
});
export const adminUserUpdateSchema = z.object({
displayName: displayNameField,
email: emailField,
role: z.enum(["ADMIN", "USER"]).optional(),
isActive: z.boolean().optional(),
/** Neues Passwort setzen; leer lassen heisst unveraendert. */
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
});
export const passkeyNameSchema = z.object({
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
});
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>;
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
export type ConnectionCreateInput = z.infer<typeof connectionCreateSchema>;
/** Formt Zod-Fehler in ein flaches Objekt fuer die API-Antwort. */
export function formatZodError(error: z.ZodError): Record<string, string> {
const result: Record<string, string> = {};
for (const issue of error.issues) {
const key = issue.path.join(".") || "_";
if (!result[key]) result[key] = issue.message;
}
return result;
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Erste Verteidigungslinie – laeuft vor jeder Anfrage.
*
* 1. CSRF: schreibende Anfragen muessen vom eigenen Origin kommen.
* 2. Sicherheitskopfzeilen inklusive Content-Security-Policy mit Nonce.
* 3. Grobe Zugangsschranke: ohne Sitzungscookie geht es zur Anmeldung.
*
* Wichtig: Punkt 3 ist nur Bequemlichkeit und Lastschutz, keine
* Sicherheitsgrenze. Die Middleware laeuft im Edge-Runtime und kann das
* Token nicht gegen die Datenbank pruefen. Verbindlich entschieden wird in
* requireUserPage bzw. requireUserApi – also serverseitig in jeder Seite und
* jedem Endpunkt.
*/
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"];
/** Der Docker-Healthcheck hat kein Cookie. */
const PUBLIC_API_EXACT = ["/api/health"];
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) return true;
if (PUBLIC_API_EXACT.includes(pathname)) return true;
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
/**
* Gleicher Ursprung?
*
* Moderne Browser senden Sec-Fetch-Site bei jeder Anfrage und Origin bei
* jeder schreibenden. Fehlen beide, stammt die Anfrage nicht aus einem
* Browser (curl, Skript, Cronjob) und ist damit kein CSRF-Vektor – solche
* Aufrufe bleiben erlaubt, damit die API skriptbar bleibt.
*/
function isSameOrigin(request: NextRequest): boolean {
const site = request.headers.get("sec-fetch-site");
if (site) return site === "same-origin" || site === "none";
const origin = request.headers.get("origin");
if (origin) {
try {
return new URL(origin).host === request.headers.get("host");
} catch {
return false;
}
}
return true;
}
function buildCsp(nonce: string, isDev: boolean): string {
const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval' 'strict-dynamic'`
: `'self' 'nonce-${nonce}' 'strict-dynamic'`;
return [
"default-src 'self'",
`script-src ${scriptSrc}`,
// Inline-Styles sind noetig: React setzt style-Attribute fuer die
// Akzentfarben, Next liefert kritisches CSS inline aus.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
// Der Browser spricht nur mit der eigenen Instanz; die Git-APIs ruft
// ausschliesslich der Server auf.
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"manifest-src 'self'",
].join("; ");
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isDev = process.env.NODE_ENV === "development";
// ── 1. CSRF ────────────────────────────────────────────────
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
return NextResponse.json(
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
{ status: 403 },
);
}
// ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const target = request.nextUrl.clone();
target.pathname = "/login";
target.search = "";
// Nur den Pfad merken, keine absolute URL – sonst waere das ein
// offener Redirect.
if (pathname !== "/") target.searchParams.set("weiter", pathname);
return NextResponse.redirect(target);
}
// ── 3. Kopfzeilen ──────────────────────────────────────────
const nonce = crypto.randomUUID().replace(/-/g, "");
const csp = buildCsp(nonce, isDev);
// Next liest die Nonce aus der CSP der Anfrage und setzt sie auf seine
// eigenen Skript-Tags.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "same-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
);
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
response.headers.set("X-DNS-Prefetch-Control", "off");
return response;
}
export const config = {
matcher: [
/*
* Alles ausser den statischen Auslieferungen von Next. Die brauchen
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
*/
{
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
+59
View File
@@ -0,0 +1,59 @@
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types";
/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */
export interface ConnectionSafe {
id: string;
name: string;
provider: GitProvider;
baseUrl: string;
username: string | null;
tokenHint: string | null;
lastCheckedAt: Date | string | null;
lastCheckOk: boolean | null;
lastCheckMessage: string | null;
createdAt: Date | string;
updatedAt: Date | string;
}
export interface ConnectionWithCount extends ConnectionSafe {
_count?: { projects: number };
}
export interface ProjectListItem extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
_count: { notes: number };
}
export interface ProjectDetail extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
notes: Note[];
_count: { notes: number };
}
export interface RepoListEntry {
fullPath: string;
owner: string;
name: string;
description: string | null;
htmlUrl: string;
defaultBranch: string | null;
visibility: string | null;
updatedAt: string | null;
}
export type { BranchInfo, CommitInfo, Note, ProjectStatus, ReleaseInfo, RepoSnapshot, TagInfo };
/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */
export function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
export function asRecord(value: unknown): Record<string, number> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, number>;
}
return {};
}