Compare commits
1
Commits
f55e5ff2fe
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceafcc6d1e |
@@ -1,78 +0,0 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# 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
@@ -1,72 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
-306
@@ -1,306 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
### 4.4 Automatischer Abgleich
|
|
||||||
|
|
||||||
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
|
|
||||||
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
|
|
||||||
|
|
||||||
| Einstellung | Bedeutung |
|
|
||||||
| --- | --- |
|
|
||||||
| Abstand | 15 Minuten bis täglich |
|
|
||||||
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
|
|
||||||
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
|
|
||||||
|
|
||||||
Wie der Durchlauf arbeitet:
|
|
||||||
|
|
||||||
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
|
|
||||||
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
|
|
||||||
Cron. Zwei Durchläufe überschneiden sich nie.
|
|
||||||
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
|
|
||||||
nächsten Mal.
|
|
||||||
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
|
|
||||||
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
|
|
||||||
Token blockiert damit nicht dauerhaft das Kontingent.
|
|
||||||
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
|
|
||||||
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
|
|
||||||
synchronisieren geht unabhängig davon immer.
|
|
||||||
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
|
|
||||||
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
|
|
||||||
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
|
||||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
|
||||||
|
|
||||||
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
|
|
||||||
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
|
|
||||||
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
|
|
||||||
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
|
||||||
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
|
|
||||||
- **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
|
|
||||||
|
|
||||||
Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
|
|
||||||
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
<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)**,
|
|
||||||
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.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.
|
|
||||||
|
|
||||||
### Automatisch statt auf Knopfdruck
|
|
||||||
|
|
||||||
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
|
|
||||||
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
|
|
||||||
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
|
|
||||||
jeder Durchlauf API-Kontingent verbraucht.
|
|
||||||
|
|
||||||
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
|
|
||||||
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
|
|
||||||
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
|
|
||||||
Projektdialog ausnehmen; manuell synchronisieren geht immer.
|
|
||||||
|
|
||||||
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
|
|
||||||
`POST /api/admin/sync/run` per eigenem Cron auf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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, Registrierung, Abgleich (Admin) |
|
|
||||||
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
|
|
||||||
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
|
|
||||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
|
||||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
|
||||||
| `GET` `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).
|
|
||||||
-178
@@ -1,178 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
|
|
||||||
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
|
|
||||||
|
|
||||||
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
|
|
||||||
|
|
||||||
Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1 · Als Nächstes empfohlen
|
|
||||||
|
|
||||||
### 1.1 Export und Import `M`
|
|
||||||
|
|
||||||
Dein Vorschlag, in vier Stufen — jede für sich nutzbar:
|
|
||||||
|
|
||||||
| Stufe | Inhalt |
|
|
||||||
| --- | --- |
|
|
||||||
| **Projekt-Export** | Ein Projekt oder alle als JSON: Stammdaten, Notizen, Repo-Verknüpfung. Ein Klick auf der Projektseite, einer im Dashboard für alles. |
|
|
||||||
| **Projekt-Import** | JSON hochladen, Vorschau, dann übernehmen. Konfliktstrategie wählbar: überspringen · umbenennen · überschreiben. |
|
|
||||||
| **Markdown-Export** | Ein Projekt als lesbares `.md` — Beschreibung, Notizen, aktueller Git-Stand. Gut zum Archivieren oder für ein README. |
|
|
||||||
| **Voll-Backup** | `pg_dump` aus dem Container heraus, per Knopf im Adminbereich als Datei. Plus dokumentierter Rückweg. |
|
|
||||||
|
|
||||||
**Offene Fragen, die wir klären sollten:**
|
|
||||||
- **Git-Tokens mitexportieren?** Mein Vorschlag: **nein**. Die Verbindung wird
|
|
||||||
mitexportiert (Name, System, Basis-URL), das Token muss beim Import neu
|
|
||||||
hinterlegt werden. Ein Export-JSON landet sonst schnell irgendwo, wo es
|
|
||||||
nicht hingehört.
|
|
||||||
- **Reichweite:** Export der eigenen Daten für jeden — Voll-Backup nur für
|
|
||||||
Administratoren?
|
|
||||||
- **Snapshots mitnehmen?** Ich würde sie weglassen: sie sind reproduzierbar
|
|
||||||
und blähen die Datei auf. Ein Sync nach dem Import holt sie zurück.
|
|
||||||
|
|
||||||
### 1.2 Markdown in Beschreibung und Notizen `S`
|
|
||||||
|
|
||||||
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
|
|
||||||
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
|
|
||||||
werden klickbar.
|
|
||||||
|
|
||||||
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
|
|
||||||
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
|
|
||||||
|
|
||||||
### 1.3 Volltextsuche über Notizen `S`
|
|
||||||
|
|
||||||
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
|
|
||||||
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
|
|
||||||
zusätzlichen Dienst.
|
|
||||||
|
|
||||||
### 1.4 Kanban-Board mit Drag & Drop `M`
|
|
||||||
|
|
||||||
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
|
|
||||||
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
|
|
||||||
|
|
||||||
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
|
|
||||||
bleibt die Listenansicht.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2 · Danach
|
|
||||||
|
|
||||||
### 2.1 Offene Pull Requests und Issues als Liste `S`
|
|
||||||
|
|
||||||
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
|
|
||||||
nützlicher — die Daten holen wir ohnehin schon ab.
|
|
||||||
|
|
||||||
### 2.2 CI-Status je Branch `M`
|
|
||||||
|
|
||||||
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
|
|
||||||
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
|
|
||||||
Endpunkt und einen zusätzlichen Token-Scope.
|
|
||||||
|
|
||||||
### 2.3 Webhooks statt Polling `M`
|
|
||||||
|
|
||||||
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
|
||||||
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
|
|
||||||
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
|
|
||||||
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
|
|
||||||
|
|
||||||
### 2.4 Commit-Aktivität als Verlauf `S`
|
|
||||||
|
|
||||||
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
|
|
||||||
da, es fehlt nur die Darstellung.
|
|
||||||
|
|
||||||
### 2.5 Aufgaben und Checklisten `M`
|
|
||||||
|
|
||||||
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
|
|
||||||
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
|
|
||||||
|
|
||||||
### 2.6 Benachrichtigungen `M`
|
|
||||||
|
|
||||||
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
|
|
||||||
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
|
|
||||||
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3 · Größere Brocken
|
|
||||||
|
|
||||||
### 3.1 Automatisierte Tests und CI `L`
|
|
||||||
|
|
||||||
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
|
|
||||||
Browser-Screenshots. Das gehört in eine Pipeline:
|
|
||||||
|
|
||||||
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
|
|
||||||
Backoff-Berechnung
|
|
||||||
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
|
|
||||||
Datentrennung
|
|
||||||
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
|
|
||||||
|
|
||||||
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
|
|
||||||
länger lebt: er macht alles Weitere billiger und sicherer.
|
|
||||||
|
|
||||||
### 3.2 Mehrere Repositories je Projekt `M`
|
|
||||||
|
|
||||||
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischentabelle
|
|
||||||
und Anpassungen in der Anzeige.
|
|
||||||
|
|
||||||
### 3.3 Zwei-Faktor per TOTP `M`
|
|
||||||
|
|
||||||
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
|
|
||||||
erreichbar ist und nicht jedes Gerät Passkeys kann.
|
|
||||||
|
|
||||||
### 3.4 Passwort-Zurücksetzen per E-Mail `M`
|
|
||||||
|
|
||||||
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
|
|
||||||
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
|
|
||||||
|
|
||||||
### 3.5 Mehrsprachigkeit `L`
|
|
||||||
|
|
||||||
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
|
|
||||||
Voraussetzung, falls du das Projekt veröffentlichen willst.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4 · Kleinigkeiten
|
|
||||||
|
|
||||||
| Idee | Aufwand | Nutzen |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
|
|
||||||
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
|
|
||||||
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
|
|
||||||
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
|
|
||||||
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
|
||||||
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
|
||||||
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
|
|
||||||
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
|
|
||||||
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 |
|
|
||||||
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5 · Bewusst nicht
|
|
||||||
|
|
||||||
Damit klar ist, was **nicht** kommt und warum:
|
|
||||||
|
|
||||||
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
|
|
||||||
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
|
|
||||||
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
|
|
||||||
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
|
|
||||||
Übersicht, keine Entwicklungsumgebung.
|
|
||||||
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
|
|
||||||
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
|
|
||||||
der Punkt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Erledigt
|
|
||||||
|
|
||||||
| | |
|
|
||||||
| --- | --- |
|
|
||||||
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
|
|
||||||
| GitHub, GitLab und Gitea | ✓ |
|
|
||||||
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
|
|
||||||
| Einzel- und Mehrbenutzerbetrieb | ✓ |
|
|
||||||
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
|
|
||||||
| Docker- und Standalone-Betrieb | ✓ |
|
|
||||||
| Automatischer Abgleich mit Zeitplan | ✓ |
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#!/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 "$@"
|
|
||||||
Vendored
-1
@@ -1,6 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference path="./.next/types/routes.d.ts" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+6
-1
@@ -7,7 +7,12 @@ const nextConfig: NextConfig = {
|
|||||||
output: "standalone",
|
output: "standalone",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
|
experimental: {
|
||||||
|
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: "2mb",
|
||||||
|
},
|
||||||
|
},
|
||||||
serverExternalPackages: ["@prisma/client", "prisma"],
|
serverExternalPackages: ["@prisma/client", "prisma"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Generated
-2956
File diff suppressed because it is too large
Load Diff
@@ -30,8 +30,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "AppMode" AS ENUM ('SINGLE_USER', 'MULTI_USER');
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "GitConnection_name_key";
|
|
||||||
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "Project_slug_key";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "GitConnection" ADD COLUMN "ownerId" TEXT;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Project" ADD COLUMN "ownerId" TEXT;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"username" TEXT NOT NULL,
|
|
||||||
"displayName" TEXT,
|
|
||||||
"email" TEXT,
|
|
||||||
"passwordHash" TEXT,
|
|
||||||
"role" "UserRole" NOT NULL DEFAULT 'USER',
|
|
||||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"lastLoginAt" TIMESTAMP(3),
|
|
||||||
"failedLoginCount" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"lockedUntil" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Session" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"tokenHash" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"userAgent" TEXT,
|
|
||||||
"ip" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Credential" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"credentialId" TEXT NOT NULL,
|
|
||||||
"publicKey" BYTEA NOT NULL,
|
|
||||||
"counter" INTEGER NOT NULL DEFAULT 0,
|
|
||||||
"transports" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
|
||||||
"deviceType" TEXT,
|
|
||||||
"backedUp" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"name" TEXT,
|
|
||||||
"lastUsedAt" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Credential_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "WebAuthnChallenge" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"challenge" TEXT NOT NULL,
|
|
||||||
"userId" TEXT,
|
|
||||||
"purpose" TEXT NOT NULL,
|
|
||||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "WebAuthnChallenge_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "AppSettings" (
|
|
||||||
"id" TEXT NOT NULL DEFAULT 'singleton',
|
|
||||||
"mode" "AppMode" NOT NULL DEFAULT 'SINGLE_USER',
|
|
||||||
"allowRegistration" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"rpName" TEXT NOT NULL DEFAULT 'Nebula',
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "AppSettings_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Session_tokenHash_key" ON "Session"("tokenHash");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Credential_credentialId_key" ON "Credential"("credentialId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Credential_userId_idx" ON "Credential"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "WebAuthnChallenge_challenge_key" ON "WebAuthnChallenge"("challenge");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "WebAuthnChallenge_expiresAt_idx" ON "WebAuthnChallenge"("expiresAt");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "GitConnection_ownerId_idx" ON "GitConnection"("ownerId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "GitConnection_ownerId_name_key" ON "GitConnection"("ownerId", "name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Project_ownerId_slug_key" ON "Project"("ownerId", "slug");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "GitConnection" ADD CONSTRAINT "GitConnection_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
|
|
||||||
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
|
|
||||||
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "lastAutoSyncReport" JSONB;
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// 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")
|
|
||||||
|
|
||||||
// ── Automatischer Abgleich mit den Git-Systemen ────────────
|
|
||||||
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
|
|
||||||
/// das soll eine bewusste Entscheidung sein.
|
|
||||||
autoSyncEnabled Boolean @default(false)
|
|
||||||
/// Abstand zwischen zwei Durchlaeufen in Minuten.
|
|
||||||
autoSyncIntervalMinutes Int @default(60)
|
|
||||||
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
|
|
||||||
/// noch etwas.
|
|
||||||
autoSyncActiveOnly Boolean @default(true)
|
|
||||||
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
|
|
||||||
/// einen Schlag in ein Rate-Limit laeuft.
|
|
||||||
autoSyncBatchSize Int @default(25)
|
|
||||||
|
|
||||||
lastAutoSyncAt DateTime?
|
|
||||||
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
|
|
||||||
lastAutoSyncReport Json?
|
|
||||||
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
|
||||||
model GitConnection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
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?
|
|
||||||
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
|
|
||||||
/// einem Repo, dessen Token staendig Fehler wirft.
|
|
||||||
autoSync Boolean @default(true)
|
|
||||||
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
|
|
||||||
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
|
|
||||||
syncFailureCount Int @default(0)
|
|
||||||
|
|
||||||
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
|
|
||||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
|
||||||
ownerId String?
|
|
||||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
notes Note[]
|
|
||||||
snapshot RepoSnapshot?
|
|
||||||
|
|
||||||
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
@@ -1,121 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/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: "[0m",
|
|
||||||
dim: "[2m",
|
|
||||||
green: "[32m",
|
|
||||||
yellow: "[33m",
|
|
||||||
cyan: "[36m",
|
|
||||||
};
|
|
||||||
|
|
||||||
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("");
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { AccountManager, type AccountData } from "@/components/account/AccountManager";
|
|
||||||
import { requireUserPage } from "@/lib/auth/guard";
|
|
||||||
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
return { title: "Mein Konto" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AccountPage() {
|
|
||||||
const user = await requireUserPage();
|
|
||||||
|
|
||||||
const [passkeys, sessions] = await Promise.all([
|
|
||||||
prisma.credential.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
deviceType: true,
|
|
||||||
backedUp: true,
|
|
||||||
createdAt: true,
|
|
||||||
lastUsedAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
}),
|
|
||||||
prisma.session.findMany({
|
|
||||||
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
userAgent: true,
|
|
||||||
ip: true,
|
|
||||||
createdAt: true,
|
|
||||||
lastSeenAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { lastSeenAt: "desc" },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const data: AccountData = {
|
|
||||||
username: user.username,
|
|
||||||
displayName: user.displayName,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
hasPassword: user.hasPassword,
|
|
||||||
passkeys,
|
|
||||||
sessions,
|
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6 pt-4">
|
|
||||||
<header className="fade-up">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-white">Mein Konto</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Profil, Anmeldemittel und aktive Sitzungen.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<AccountManager data={data} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import {
|
|
||||||
AdminManager,
|
|
||||||
type AdminData,
|
|
||||||
type AutoSyncReport,
|
|
||||||
} from "@/components/admin/AdminManager";
|
|
||||||
import { requireAdminPage } from "@/lib/auth/guard";
|
|
||||||
import { getAppSettings } from "@/lib/auth/settings";
|
|
||||||
import { userSafeSelect } from "@/lib/auth/users";
|
|
||||||
import { APP_URL, WEBAUTHN_AVAILABLE } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
return { title: "Admin-Einstellungen" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AdminPage() {
|
|
||||||
const admin = await requireAdminPage();
|
|
||||||
|
|
||||||
const [settings, users, eligible] = await Promise.all([
|
|
||||||
getAppSettings(),
|
|
||||||
prisma.user.findMany({
|
|
||||||
select: userSafeSelect,
|
|
||||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
|
||||||
}),
|
|
||||||
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
|
|
||||||
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
|
|
||||||
prisma.project.count({
|
|
||||||
where: {
|
|
||||||
autoSync: true,
|
|
||||||
OR: [
|
|
||||||
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
|
|
||||||
{ repoUrl: { not: null } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const data: AdminData = {
|
|
||||||
mode: settings.mode,
|
|
||||||
allowRegistration: settings.allowRegistration,
|
|
||||||
users,
|
|
||||||
currentUserId: admin.id,
|
|
||||||
appUrl: APP_URL,
|
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
|
||||||
autoSync: {
|
|
||||||
enabled: settings.autoSyncEnabled,
|
|
||||||
intervalMinutes: settings.autoSyncIntervalMinutes,
|
|
||||||
activeOnly: settings.autoSyncActiveOnly,
|
|
||||||
batchSize: settings.autoSyncBatchSize,
|
|
||||||
lastRunAt: settings.lastAutoSyncAt,
|
|
||||||
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
|
|
||||||
eligible,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl space-y-6 pt-4">
|
|
||||||
<header className="fade-up">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
|
|
||||||
Administration
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white">
|
|
||||||
Admin-Einstellungen
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<AdminManager data={data} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { formatZodError, passkeyNameSchema } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
/** Umbenennen – nur der eigene Passkey. */
|
|
||||||
export async function PATCH(request: Request, { params }: Params) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = passkeyNameSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Der Besitzvergleich steht in der where-Klausel, damit fremde Passkeys
|
|
||||||
// gar nicht erst getroffen werden.
|
|
||||||
const result = await prisma.credential.updateMany({
|
|
||||||
where: { id, userId: auth.user.id },
|
|
||||||
data: { name: parsed.data.name ?? null },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: Params) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { id: auth.user.id },
|
|
||||||
select: { passwordHash: true, _count: { select: { credentials: true } } },
|
|
||||||
});
|
|
||||||
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
|
||||||
|
|
||||||
// Nicht das letzte Anmeldemittel entfernen.
|
|
||||||
if (!user.passwordHash && user._count.credentials <= 1) {
|
|
||||||
return jsonError(
|
|
||||||
"Das ist der letzte Passkey und es ist kein Passwort gesetzt. " +
|
|
||||||
"Bitte zuerst ein Passwort vergeben.",
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await prisma.credential.deleteMany({ where: { id, userId: auth.user.id } });
|
|
||||||
if (result.count === 0) return jsonError("Passkey nicht gefunden.", 404);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { hashPassword, validatePasswordStrength, verifyPassword } from "@/lib/auth/password";
|
|
||||||
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { changePasswordSchema, formatZodError } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eigenes Passwort setzen oder aendern.
|
|
||||||
*
|
|
||||||
* Wer bisher nur einen Passkey hat, darf ohne altes Passwort eines
|
|
||||||
* vergeben – die Sitzung selbst ist der Nachweis. Nach der Aenderung werden
|
|
||||||
* alle Sitzungen verworfen und die aktuelle neu ausgestellt.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = changePasswordSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: auth.user.id } });
|
|
||||||
if (!user) return jsonError("Konto nicht gefunden.", 404);
|
|
||||||
|
|
||||||
if (user.passwordHash) {
|
|
||||||
const current = parsed.data.currentPassword ?? "";
|
|
||||||
const valid = await verifyPassword(current, user.passwordHash);
|
|
||||||
if (!valid) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: "Das aktuelle Passwort stimmt nicht.",
|
|
||||||
fields: { currentPassword: "Falsch" },
|
|
||||||
},
|
|
||||||
{ status: 401 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const weak = validatePasswordStrength(parsed.data.newPassword);
|
|
||||||
if (weak) {
|
|
||||||
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: { passwordHash: await hashPassword(parsed.data.newPassword) },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Alle bestehenden Sitzungen entwerten und fuer dieses Geraet eine neue
|
|
||||||
// ausstellen, damit der Wechsel nicht abmeldet.
|
|
||||||
await destroyAllSessions(user.id);
|
|
||||||
const session = await createSession(user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip: clientIp(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { readJsonBody } from "@/lib/security/request";
|
|
||||||
import { formatZodError, profileUpdateSchema } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function PATCH(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = profileUpdateSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { displayName, email } = parsed.data;
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
const taken = await prisma.user.findFirst({
|
|
||||||
where: { email, NOT: { id: auth.user.id } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (taken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.update({
|
|
||||||
where: { id: auth.user.id },
|
|
||||||
data: {
|
|
||||||
...(displayName !== undefined ? { displayName } : {}),
|
|
||||||
...(email !== undefined ? { email } : {}),
|
|
||||||
},
|
|
||||||
select: { id: true, username: true, displayName: true, email: true, role: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { createSession, destroyAllSessions, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { clientIp } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Uebersicht der eigenen aktiven Sitzungen. */
|
|
||||||
export async function GET() {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const sessions = await prisma.session.findMany({
|
|
||||||
where: { userId: auth.user.id, expiresAt: { gt: new Date() } },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
userAgent: true,
|
|
||||||
ip: true,
|
|
||||||
createdAt: true,
|
|
||||||
lastSeenAt: true,
|
|
||||||
expiresAt: true,
|
|
||||||
},
|
|
||||||
orderBy: { lastSeenAt: "desc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ sessions });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ueberall abmelden – das aktuelle Geraet bleibt angemeldet. */
|
|
||||||
export async function DELETE(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
await destroyAllSessions(auth.user.id);
|
|
||||||
const session = await createSession(auth.user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip: clientIp(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { getAppSettings } from "@/lib/auth/settings";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { adminSettingsSchema, formatZodError } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const [settings, userCount] = await Promise.all([getAppSettings(), prisma.user.count()]);
|
|
||||||
return NextResponse.json({ settings, userCount });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: Request) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = adminSettingsSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
mode,
|
|
||||||
allowRegistration,
|
|
||||||
autoSyncEnabled,
|
|
||||||
autoSyncIntervalMinutes,
|
|
||||||
autoSyncActiveOnly,
|
|
||||||
autoSyncBatchSize,
|
|
||||||
} = parsed.data;
|
|
||||||
|
|
||||||
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
|
|
||||||
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
|
|
||||||
if (mode === "SINGLE_USER") {
|
|
||||||
const userCount = await prisma.user.count();
|
|
||||||
if (userCount > 1) {
|
|
||||||
return jsonError(
|
|
||||||
`Einzelbetrieb ist nicht moeglich, solange ${userCount} Konten existieren. ` +
|
|
||||||
"Bitte zuerst die uebrigen Konten entfernen.",
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoSyncFields = {
|
|
||||||
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
|
|
||||||
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
|
|
||||||
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
|
|
||||||
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = await prisma.appSettings.upsert({
|
|
||||||
where: { id: "singleton" },
|
|
||||||
create: {
|
|
||||||
id: "singleton",
|
|
||||||
...(mode ? { mode } : {}),
|
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
|
||||||
...autoSyncFields,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
...(mode ? { mode } : {}),
|
|
||||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
|
||||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
|
||||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
|
||||||
...autoSyncFields,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ settings });
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
|
||||||
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
|
|
||||||
*
|
|
||||||
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
|
|
||||||
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
|
|
||||||
* eingebauten Automatismus zu nutzen.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const limited = rateLimit(
|
|
||||||
`sync-run:${clientIp(request)}`,
|
|
||||||
LIMITS.outbound.limit,
|
|
||||||
LIMITS.outbound.windowMs,
|
|
||||||
);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
if (isAutoSyncRunning()) {
|
|
||||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = await runAutoSyncPass("manuell");
|
|
||||||
if (!report) {
|
|
||||||
return jsonError("Es laeuft bereits ein Abgleich.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ report });
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
|
||||||
import { destroyAllSessions } from "@/lib/auth/session";
|
|
||||||
import { userSafeSelect } from "@/lib/auth/users";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
|
||||||
|
|
||||||
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
|
|
||||||
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
|
|
||||||
const target = await prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { role: true },
|
|
||||||
});
|
|
||||||
if (target?.role !== "ADMIN") return false;
|
|
||||||
|
|
||||||
const activeAdmins = await prisma.user.count({
|
|
||||||
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
|
|
||||||
});
|
|
||||||
return activeAdmins === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: Request, { params }: Params) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = adminUserUpdateSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = await prisma.user.findUnique({ where: { id } });
|
|
||||||
if (!target) return jsonError("Konto nicht gefunden.", 404);
|
|
||||||
|
|
||||||
const { displayName, email, role, isActive, password } = parsed.data;
|
|
||||||
|
|
||||||
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
|
|
||||||
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
|
|
||||||
return jsonError(
|
|
||||||
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
const taken = await prisma.user.findFirst({
|
|
||||||
where: { email, NOT: { id } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (taken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
const weak = validatePasswordStrength(password);
|
|
||||||
if (weak) {
|
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...(displayName !== undefined ? { displayName } : {}),
|
|
||||||
...(email !== undefined ? { email } : {}),
|
|
||||||
...(role ? { role } : {}),
|
|
||||||
...(isActive !== undefined ? { isActive } : {}),
|
|
||||||
...(password ? { passwordHash: await hashPassword(password) } : {}),
|
|
||||||
// Ein Eingriff des Administrators hebt eine Sperre auf.
|
|
||||||
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
|
|
||||||
},
|
|
||||||
select: userSafeSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
|
|
||||||
if (isActive === false || password) {
|
|
||||||
await destroyAllSessions(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ user });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function DELETE(_request: Request, { params }: Params) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
if (id === auth.user.id) {
|
|
||||||
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
|
|
||||||
}
|
|
||||||
if (await wouldRemoveLastAdmin(id)) {
|
|
||||||
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
|
|
||||||
// onDelete: Cascade daran und verschwinden mit.
|
|
||||||
await prisma.user.delete({ where: { id } });
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch {
|
|
||||||
return jsonError("Konto nicht gefunden.", 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireAdminApi } from "@/lib/auth/guard";
|
|
||||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { userSafeSelect } from "@/lib/auth/users";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: userSafeSelect,
|
|
||||||
orderBy: [{ role: "asc" }, { username: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ users });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireAdminApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const state = await getInstanceState();
|
|
||||||
if (!state.multiUser) {
|
|
||||||
return jsonError(
|
|
||||||
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
|
|
||||||
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
|
|
||||||
409,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = adminUserCreateSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, displayName, email, password, role } = parsed.data;
|
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
|
||||||
if (weak) {
|
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
|
||||||
if (taken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
|
||||||
if (emailTaken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
displayName: displayName ?? null,
|
|
||||||
email: email ?? null,
|
|
||||||
passwordHash: await hashPassword(password),
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
select: userSafeSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ user }, { status: 201 });
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
|
||||||
import { loginSchema } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
|
|
||||||
const GENERIC = "Benutzername oder Passwort ist falsch.";
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const ip = clientIp(request);
|
|
||||||
|
|
||||||
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
|
||||||
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = loginSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) return jsonError(GENERIC, 401);
|
|
||||||
|
|
||||||
const { username, password } = parsed.data;
|
|
||||||
|
|
||||||
const byUser = rateLimit(
|
|
||||||
`login:user:${username}`,
|
|
||||||
LIMITS.loginUser.limit,
|
|
||||||
LIMITS.loginUser.windowMs,
|
|
||||||
);
|
|
||||||
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { username } });
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
|
|
||||||
await fakeVerifyDelay();
|
|
||||||
return jsonError(GENERIC, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const locked = lockRemainingSeconds(user.lockedUntil);
|
|
||||||
if (locked > 0) {
|
|
||||||
return jsonError(
|
|
||||||
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
|
|
||||||
423,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.isActive) {
|
|
||||||
await fakeVerifyDelay();
|
|
||||||
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.passwordHash) {
|
|
||||||
await fakeVerifyDelay();
|
|
||||||
return jsonError(
|
|
||||||
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash);
|
|
||||||
if (!valid) {
|
|
||||||
await noteFailedLogin(user.id);
|
|
||||||
return jsonError(GENERIC, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
await noteSuccessfulLogin(user.id);
|
|
||||||
resetRateLimit(`login:user:${username}`);
|
|
||||||
resetRateLimit(`login:ip:${ip}`);
|
|
||||||
|
|
||||||
const session = await createSession(user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
|
||||||
});
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { clearSessionCookie, destroyCurrentSession } from "@/lib/auth/session";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
await destroyCurrentSession();
|
|
||||||
const response = NextResponse.json({ ok: true });
|
|
||||||
clearSessionCookie(response);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { WebAuthnUnavailableError, buildAuthenticationOptions } from "@/lib/auth/webauthn";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optionen fuer navigator.credentials.get(). Ohne allowCredentials – der
|
|
||||||
* Browser bietet alle passenden Passkeys an, ein Benutzername ist nicht
|
|
||||||
* noetig. Der Endpunkt verraet dadurch auch nichts ueber vorhandene Konten.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const limited = rateLimit(
|
|
||||||
`webauthn:auth:${clientIp(request)}`,
|
|
||||||
LIMITS.webauthn.limit,
|
|
||||||
LIMITS.webauthn.windowMs,
|
|
||||||
);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const options = await buildAuthenticationOptions();
|
|
||||||
return NextResponse.json({ options });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
|
||||||
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
|
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { noteSuccessfulLogin } from "@/lib/auth/users";
|
|
||||||
import { WebAuthnUnavailableError, confirmAuthentication } from "@/lib/auth/webauthn";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
interface Payload {
|
|
||||||
response?: AuthenticationResponseJSON;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const ip = clientIp(request);
|
|
||||||
const limited = rateLimit(`passkey:login:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
const body = await readJsonBody<Payload>(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const assertion = body.data?.response;
|
|
||||||
if (!assertion || typeof assertion !== "object") {
|
|
||||||
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await confirmAuthentication(assertion);
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: result.userId } });
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
await noteSuccessfulLogin(user.id);
|
|
||||||
|
|
||||||
const session = await createSession(user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
|
||||||
});
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
|
||||||
return jsonError(error instanceof Error ? error.message : String(error), 401);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { WebAuthnUnavailableError, buildRegistrationOptions } from "@/lib/auth/webauthn";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Liefert die Optionen fuer navigator.credentials.create(). */
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const limited = rateLimit(
|
|
||||||
`webauthn:reg:${clientIp(request)}`,
|
|
||||||
LIMITS.webauthn.limit,
|
|
||||||
LIMITS.webauthn.windowMs,
|
|
||||||
);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const options = await buildRegistrationOptions({
|
|
||||||
id: auth.user.id,
|
|
||||||
username: auth.user.username,
|
|
||||||
displayName: auth.user.displayName,
|
|
||||||
});
|
|
||||||
return NextResponse.json({ options });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
|
||||||
return jsonError(error instanceof Error ? error.message : String(error), 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { RegistrationResponseJSON } from "@simplewebauthn/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { WebAuthnUnavailableError, confirmRegistration } from "@/lib/auth/webauthn";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
interface Payload {
|
|
||||||
response?: RegistrationResponseJSON;
|
|
||||||
name?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nimmt die Antwort des Authenticators entgegen und speichert den Passkey. */
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth.response;
|
|
||||||
|
|
||||||
const body = await readJsonBody<Payload>(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const attestation = body.data?.response;
|
|
||||||
if (!attestation || typeof attestation !== "object") {
|
|
||||||
return jsonError("Die Antwort des Authenticators fehlt.", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credential = await confirmRegistration(
|
|
||||||
auth.user.id,
|
|
||||||
attestation,
|
|
||||||
typeof body.data?.name === "string" ? body.data.name : null,
|
|
||||||
);
|
|
||||||
return NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
credential: {
|
|
||||||
id: credential.id,
|
|
||||||
name: credential.name,
|
|
||||||
deviceType: credential.deviceType,
|
|
||||||
backedUp: credential.backedUp,
|
|
||||||
createdAt: credential.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof WebAuthnUnavailableError) return jsonError(error.message, 501);
|
|
||||||
return jsonError(error instanceof Error ? error.message : String(error), 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
|
||||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
|
||||||
import { formatZodError, registerSchema } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selbstregistrierung. Nur moeglich, wenn die Instanz im Mehrbenutzerbetrieb
|
|
||||||
* laeuft und ein Administrator die Registrierung freigeschaltet hat.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const ip = clientIp(request);
|
|
||||||
const limited = rateLimit(`register:${ip}`, 5, 60 * 60 * 1000);
|
|
||||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
|
||||||
|
|
||||||
const state = await getInstanceState();
|
|
||||||
if (state.needsSetup) {
|
|
||||||
return jsonError("Die Instanz ist noch nicht eingerichtet.", 409);
|
|
||||||
}
|
|
||||||
if (!state.registrationOpen) {
|
|
||||||
return jsonError("Die Registrierung ist auf dieser Instanz deaktiviert.", 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = registerSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, displayName, email, password } = parsed.data;
|
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
|
||||||
if (weak) {
|
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
|
||||||
if (taken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
|
||||||
if (emailTaken) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
|
||||||
{ status: 409 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
displayName: displayName ?? null,
|
|
||||||
email: email ?? null,
|
|
||||||
passwordHash: await hashPassword(password),
|
|
||||||
role: "USER",
|
|
||||||
// Die Registrierung meldet direkt an.
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const session = await createSession(user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
|
||||||
});
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { WEBAUTHN_AVAILABLE } from "@/lib/config";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/** Zustand fuer die Anmeldeseite und zum Pruefen der eigenen Sitzung. */
|
|
||||||
export async function GET() {
|
|
||||||
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
user,
|
|
||||||
needsSetup: state.needsSetup,
|
|
||||||
registrationOpen: state.registrationOpen,
|
|
||||||
multiUser: state.multiUser,
|
|
||||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
|
||||||
import { claimOrphanedData } from "@/lib/auth/users";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { formatZodError, setupSchema } from "@/lib/validation";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Erstmalige Einrichtung: legt das Administratorkonto an.
|
|
||||||
*
|
|
||||||
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
|
|
||||||
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
|
|
||||||
* erschleichen.
|
|
||||||
*/
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
const existing = await prisma.user.count();
|
|
||||||
if (existing > 0) {
|
|
||||||
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await readJsonBody(request);
|
|
||||||
if (!body.ok) return body.response;
|
|
||||||
|
|
||||||
const parsed = setupSchema.safeParse(body.data);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, displayName, email, password, mode } = parsed.data;
|
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
|
||||||
if (weak) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: weak, fields: { password: weak } },
|
|
||||||
{ status: 422 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
username,
|
|
||||||
displayName: displayName ?? null,
|
|
||||||
email: email ?? null,
|
|
||||||
passwordHash: await hashPassword(password),
|
|
||||||
role: "ADMIN",
|
|
||||||
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
|
||||||
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.appSettings.upsert({
|
|
||||||
where: { id: "singleton" },
|
|
||||||
create: { id: "singleton", mode },
|
|
||||||
update: { mode },
|
|
||||||
});
|
|
||||||
|
|
||||||
const claimed = await claimOrphanedData(user.id);
|
|
||||||
|
|
||||||
const session = await createSession(user.id, {
|
|
||||||
userAgent: request.headers.get("user-agent"),
|
|
||||||
ip: clientIp(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = NextResponse.json({
|
|
||||||
ok: true,
|
|
||||||
user: { id: user.id, username: user.username, role: user.role },
|
|
||||||
claimed,
|
|
||||||
});
|
|
||||||
setSessionCookie(response, session.token, session.expiresAt);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { formatZodError, 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 });
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireUserApi } from "@/lib/auth/guard";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
||||||
import { formatZodError, 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 });
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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",
|
|
||||||
"autoSync",
|
|
||||||
"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 });
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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,
|
|
||||||
autoSync: data.autoSync,
|
|
||||||
repoUrl: data.repoUrl ?? null,
|
|
||||||
repoOwner,
|
|
||||||
repoName,
|
|
||||||
connectionId: data.connectionId ?? null,
|
|
||||||
},
|
|
||||||
include: projectListInclude,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ project }, { status: 201 });
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
/* ─────────────────────────────────────────────────────────────
|
|
||||||
Nebula – wiederkehrende Bausteine.
|
|
||||||
|
|
||||||
Bewusst in @layer components: Tailwind sortiert die Layer als
|
|
||||||
theme → base → components → utilities. Ohne diese Zuordnung wuerden
|
|
||||||
die Kurzschreibweisen hier (padding, border-radius …) die Utilities
|
|
||||||
ueberschreiben, weil sie spaeter im Stylesheet stehen – aus
|
|
||||||
"field pl-9" wuerde dann wieder das linke Standardpadding und das
|
|
||||||
Suchsymbol laege auf dem Platzhaltertext.
|
|
||||||
───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
/* ── Glasflaechen ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.glass {
|
|
||||||
position: relative;
|
|
||||||
background: var(--glass-bg);
|
|
||||||
border: 1px solid var(--glass-border);
|
|
||||||
backdrop-filter: blur(18px) saturate(140%);
|
|
||||||
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 0 var(--glass-highlight) inset,
|
|
||||||
0 24px 60px -30px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-soft {
|
|
||||||
background: rgba(255, 255, 255, 0.025);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
-webkit-backdrop-filter: blur(12px);
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Karte mit Akzent-Glow. --accent-from/--accent-to setzt die Komponente. */
|
|
||||||
.card-glow {
|
|
||||||
transition:
|
|
||||||
transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
box-shadow 0.35s ease,
|
|
||||||
border-color 0.35s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-glow::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -1px;
|
|
||||||
border-radius: inherit;
|
|
||||||
padding: 1px;
|
|
||||||
background: linear-gradient(135deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(#000 0 0) content-box,
|
|
||||||
linear-gradient(#000 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
mask-composite: exclude;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.35s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-glow:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 0 var(--glass-highlight) inset,
|
|
||||||
0 30px 70px -30px rgba(0, 0, 0, 0.95),
|
|
||||||
0 0 46px -12px var(--accent-from, #8b5cf6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-glow:hover::before {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge.
|
|
||||||
*
|
|
||||||
* Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal
|
|
||||||
* was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter
|
|
||||||
* durch. backdrop-filter hilft hier nicht: liegt das Element in einem
|
|
||||||
* Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die
|
|
||||||
* Weichzeichnung wirkungslos.
|
|
||||||
*/
|
|
||||||
.popover {
|
|
||||||
/* Vollstaendig deckend: bei 97 % schimmerte darunterliegender Text
|
|
||||||
(die Schaltflaechen "Bearbeiten" und "Loeschen") noch durch. */
|
|
||||||
background: #090b18;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.11);
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
box-shadow:
|
|
||||||
0 1px 0 0 rgba(255, 255, 255, 0.07) inset,
|
|
||||||
0 28px 70px -24px rgba(0, 0, 0, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Text & Akzente ───────────────────────────────────────── */
|
|
||||||
|
|
||||||
.text-gradient {
|
|
||||||
background: linear-gradient(120deg, #c4b5fd 0%, #67e8f9 45%, #f0abfc 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accent-bar {
|
|
||||||
background: linear-gradient(90deg, var(--accent-from, #8b5cf6), var(--accent-to, #d946ef));
|
|
||||||
}
|
|
||||||
|
|
||||||
.accent-dot {
|
|
||||||
box-shadow: 0 0 12px 1px currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Buttons ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
transition:
|
|
||||||
background-color 0.2s ease,
|
|
||||||
border-color 0.2s ease,
|
|
||||||
color 0.2s ease,
|
|
||||||
box-shadow 0.2s ease,
|
|
||||||
filter 0.2s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus-visible {
|
|
||||||
outline: 2px solid rgba(167, 139, 250, 0.8);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(120deg, #7c3aed, #c026d3);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 10px 30px -12px rgba(168, 85, 247, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
filter: brightness(1.12);
|
|
||||||
box-shadow: 0 14px 36px -12px rgba(168, 85, 247, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
color: #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
background: rgba(255, 255, 255, 0.09);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: rgba(244, 63, 94, 0.12);
|
|
||||||
border-color: rgba(244, 63, 94, 0.35);
|
|
||||||
color: #fda4af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
background: rgba(244, 63, 94, 0.22);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formularfelder ───────────────────────────────────────── */
|
|
||||||
|
|
||||||
.field {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgba(8, 10, 24, 0.6);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
|
||||||
padding: 0.6rem 0.85rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #e6e8f5;
|
|
||||||
transition:
|
|
||||||
border-color 0.2s ease,
|
|
||||||
box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field::placeholder {
|
|
||||||
color: rgba(148, 163, 184, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: rgba(167, 139, 250, 0.6);
|
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(148, 163, 184, 0.85);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.field option {
|
|
||||||
background: #0a0c1a;
|
|
||||||
color: #e6e8f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schieberegler. Der native Regler zeichnet die Spur rechts vom Griff in
|
|
||||||
* einem hellen Standardton - im dunklen Layout ein grelles Band. Deshalb
|
|
||||||
* vollstaendig selbst gezeichnet: der gefuellte Anteil kommt aus der
|
|
||||||
* Variablen --fill, die die Komponente aus dem Wert setzt.
|
|
||||||
*/
|
|
||||||
.range {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
cursor: pointer;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
#8b5cf6 0%,
|
|
||||||
#d946ef var(--fill, 0%),
|
|
||||||
rgba(255, 255, 255, 0.1) var(--fill, 0%),
|
|
||||||
rgba(255, 255, 255, 0.1) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.range:focus-visible {
|
|
||||||
outline: 2px solid rgba(167, 139, 250, 0.8);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #f5f3ff;
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-webkit-slider-thumb:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-moz-range-thumb {
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: #f5f3ff;
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range::-moz-range-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 758 B |
@@ -1,61 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { AuthShell } from "@/components/auth/AuthShell";
|
|
||||||
import { LoginForm } from "@/components/auth/LoginForm";
|
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { APP_NAME, WEBAUTHN_AVAILABLE } from "@/lib/config";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
return { title: "Anmelden" };
|
|
||||||
}
|
|
||||||
|
|
||||||
type PageProps = { searchParams: Promise<{ weiter?: string }> };
|
|
||||||
|
|
||||||
export default async function LoginPage({ searchParams }: PageProps) {
|
|
||||||
const [user, state, params] = await Promise.all([
|
|
||||||
getSessionUser(),
|
|
||||||
getInstanceState(),
|
|
||||||
searchParams,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (state.needsSetup) redirect("/setup");
|
|
||||||
if (user) redirect("/");
|
|
||||||
|
|
||||||
// Nur relative Pfade zulassen – sonst waere das ein offener Redirect.
|
|
||||||
const raw = params.weiter ?? "/";
|
|
||||||
const next = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell
|
|
||||||
appName={APP_NAME}
|
|
||||||
title="Willkommen zurueck"
|
|
||||||
subtitle="Melde dich an, um deine Projekte zu sehen."
|
|
||||||
>
|
|
||||||
<LoginForm
|
|
||||||
passkeysAvailable={WEBAUTHN_AVAILABLE}
|
|
||||||
registrationOpen={state.registrationOpen}
|
|
||||||
next={next}
|
|
||||||
/>
|
|
||||||
</AuthShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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}
|
|
||||||
// Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
|
|
||||||
// der schmalen Kachel mitten im Wort abgeschnitten.
|
|
||||||
hint="Noch nichts gebaut"
|
|
||||||
icon={Lightbulb}
|
|
||||||
color={STATUS_META.IDEA.color}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { AuthShell } from "@/components/auth/AuthShell";
|
|
||||||
import { RegisterForm } from "@/components/auth/RegisterForm";
|
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
return { title: "Registrieren" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
|
||||||
const [user, state] = await Promise.all([getSessionUser(), getInstanceState()]);
|
|
||||||
|
|
||||||
if (state.needsSetup) redirect("/setup");
|
|
||||||
if (user) redirect("/");
|
|
||||||
if (!state.registrationOpen) redirect("/login");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell
|
|
||||||
appName={APP_NAME}
|
|
||||||
title="Konto anlegen"
|
|
||||||
subtitle="Deine Projekte, Notizen und Git-Verbindungen sind nur fuer dich sichtbar."
|
|
||||||
>
|
|
||||||
<RegisterForm />
|
|
||||||
</AuthShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { AuthShell } from "@/components/auth/AuthShell";
|
|
||||||
import { SetupForm } from "@/components/auth/SetupForm";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
return { title: "Einrichtung" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SetupPage() {
|
|
||||||
const state = await getInstanceState();
|
|
||||||
|
|
||||||
// Nach der Einrichtung ist diese Seite verschlossen.
|
|
||||||
if (!state.needsSetup) redirect("/login");
|
|
||||||
|
|
||||||
// Bestandsdaten anzeigen, damit klar ist, was das neue Konto uebernimmt.
|
|
||||||
const [orphanProjects, orphanConnections] = await Promise.all([
|
|
||||||
prisma.project.count({ where: { ownerId: null } }),
|
|
||||||
prisma.gitConnection.count({ where: { ownerId: null } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthShell
|
|
||||||
appName={APP_NAME}
|
|
||||||
title="Erste Einrichtung"
|
|
||||||
subtitle="Lege das Administratorkonto an. Danach ist diese Seite gesperrt."
|
|
||||||
footer={
|
|
||||||
orphanProjects + orphanConnections > 0 ? (
|
|
||||||
<>
|
|
||||||
{orphanProjects} vorhandene Projekte und {orphanConnections} Git-Verbindungen
|
|
||||||
werden diesem Konto zugeordnet.
|
|
||||||
</>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SetupForm />
|
|
||||||
</AuthShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
"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;
|
|
||||||
autoSync: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY: FormState = {
|
|
||||||
name: "",
|
|
||||||
summary: "",
|
|
||||||
description: "",
|
|
||||||
status: "IDEA",
|
|
||||||
priority: 2,
|
|
||||||
progress: 0,
|
|
||||||
accent: "violet",
|
|
||||||
tags: "",
|
|
||||||
favorite: false,
|
|
||||||
connectionId: "",
|
|
||||||
repoUrl: "",
|
|
||||||
repoOwner: "",
|
|
||||||
repoName: "",
|
|
||||||
autoSync: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 ?? "",
|
|
||||||
autoSync: project.autoSync,
|
|
||||||
}
|
|
||||||
: 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,
|
|
||||||
autoSync: form.autoSync,
|
|
||||||
};
|
|
||||||
|
|
||||||
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="range"
|
|
||||||
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.autoSync}
|
|
||||||
onChange={(event) => update("autoSync", event.target.checked)}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Am automatischen Abgleich teilnehmen
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-600">
|
|
||||||
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
|
|
||||||
synchronisieren geht unabhaengig davon immer.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
|
|
||||||
|
|
||||||
{repoPickerOpen ? (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
"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>
|
|
||||||
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
|
|
||||||
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
value={progress}
|
|
||||||
onChange={(event) => setProgress(Number(event.target.value))}
|
|
||||||
onMouseUp={() => patch({ progress })}
|
|
||||||
onTouchEnd={() => patch({ progress })}
|
|
||||||
onKeyUp={() => patch({ progress })}
|
|
||||||
aria-label="Fortschritt anpassen"
|
|
||||||
className="range"
|
|
||||||
style={{ "--fill": `${progress}%` } as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectDialog
|
|
||||||
open={editOpen}
|
|
||||||
onClose={() => setEditOpen(false)}
|
|
||||||
connections={connections}
|
|
||||||
project={project}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState, useTransition } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
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 triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setOpen((value) => !value);
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<Popover
|
|
||||||
anchorRef={triggerRef}
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
role="listbox"
|
|
||||||
width={264}
|
|
||||||
>
|
|
||||||
{STATUS_ORDER.map((value) => {
|
|
||||||
const option = STATUS_META[value];
|
|
||||||
const active = value === current;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
aria-selected={active}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
void select(value);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
|
||||||
active ? "bg-white/8" : "hover:bg-white/6",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: option.color }}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="flex items-center gap-2 text-sm text-slate-100">
|
|
||||||
{option.label}
|
|
||||||
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
|
||||||
{option.description}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
GitBranch,
|
|
||||||
LayoutDashboard,
|
|
||||||
LogOut,
|
|
||||||
Settings,
|
|
||||||
ShieldCheck,
|
|
||||||
Sparkles,
|
|
||||||
UserRound,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Popover } from "@/components/ui/Popover";
|
|
||||||
import type { SessionUser } from "@/lib/auth/session";
|
|
||||||
import { 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 triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
setBusy(true);
|
|
||||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => undefined);
|
|
||||||
setOpen(false);
|
|
||||||
router.replace("/login");
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = user.displayName?.trim() || user.username;
|
|
||||||
const initials = label.slice(0, 2).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((value) => !value)}
|
|
||||||
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-gradient-to-br from-violet-500/80 to-cyan-400/70 text-[11px] font-semibold text-white">
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
<span className="hidden max-w-[10rem] truncate sm:inline">{label}</span>
|
|
||||||
{user.role === "ADMIN" ? (
|
|
||||||
<ShieldCheck className="hidden h-3.5 w-3.5 text-violet-300 sm:inline" />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
|
||||||
<div className="border-b border-white/6 px-3 py-2">
|
|
||||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
|
||||||
<p className="truncate text-[11px] text-slate-500">
|
|
||||||
@{user.username}
|
|
||||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/account"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<UserRound className="h-4 w-4" />
|
|
||||||
Mein Konto
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{user.role === "ADMIN" ? (
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
Admin-Einstellungen
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={logout}
|
|
||||||
disabled={busy}
|
|
||||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4" />
|
|
||||||
Abmelden
|
|
||||||
</button>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,530 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { startRegistration } from "@simplewebauthn/browser";
|
|
||||||
import {
|
|
||||||
Fingerprint,
|
|
||||||
KeyRound,
|
|
||||||
Loader2,
|
|
||||||
LogOut,
|
|
||||||
Monitor,
|
|
||||||
Pencil,
|
|
||||||
Save,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
|
||||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface PasskeyEntry {
|
|
||||||
id: string;
|
|
||||||
name: string | null;
|
|
||||||
deviceType: string | null;
|
|
||||||
backedUp: boolean;
|
|
||||||
createdAt: string | Date;
|
|
||||||
lastUsedAt: string | Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionEntry {
|
|
||||||
id: string;
|
|
||||||
userAgent: string | null;
|
|
||||||
ip: string | null;
|
|
||||||
createdAt: string | Date;
|
|
||||||
lastSeenAt: string | Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountData {
|
|
||||||
username: string;
|
|
||||||
displayName: string | null;
|
|
||||||
email: string | null;
|
|
||||||
role: "ADMIN" | "USER";
|
|
||||||
hasPassword: boolean;
|
|
||||||
passkeys: PasskeyEntry[];
|
|
||||||
sessions: SessionEntry[];
|
|
||||||
passkeysAvailable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
|
||||||
|
|
||||||
export function AccountManager({ data }: { data: AccountData }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<ProfileSection data={data} />
|
|
||||||
<PasswordSection hasPassword={data.hasPassword} />
|
|
||||||
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
|
|
||||||
<SessionSection sessions={data.sessions} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon: Icon,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon: typeof KeyRound;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{description ? (
|
|
||||||
<p className="mt-1 text-sm text-slate-400">{description}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoticeLine({ notice }: { notice: Notice }) {
|
|
||||||
if (!notice) return null;
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
|
||||||
notice.ok
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
|
||||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notice.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileSection({ data }: { data: AccountData }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [displayName, setDisplayName] = useState(data.displayName ?? "");
|
|
||||||
const [email, setEmail] = useState(data.email ?? "");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
async function save(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/account/profile", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ displayName, email }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
|
||||||
setNotice({ ok: true, text: "Profil gespeichert." });
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard title="Profil" icon={Pencil}>
|
|
||||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<span className="field-label">Benutzername</span>
|
|
||||||
<p className="font-mono text-sm text-slate-400">@{data.username}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-600">
|
|
||||||
{data.role === "ADMIN" ? "Administrator" : "Benutzer"} · nicht aenderbar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div />
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="account-display">
|
|
||||||
Anzeigename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-display"
|
|
||||||
className="field"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="account-email">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-email"
|
|
||||||
className="field"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
placeholder="optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
<NoticeLine notice={notice} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasswordSection({ hasPassword }: { hasPassword: boolean }) {
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [repeat, setRepeat] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
async function save(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (newPassword !== repeat) {
|
|
||||||
setNotice({ ok: false, text: "Die Passwoerter stimmen nicht ueberein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/account/password", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ currentPassword, newPassword }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Aenderung fehlgeschlagen.");
|
|
||||||
setNotice({
|
|
||||||
ok: true,
|
|
||||||
text: "Passwort gesetzt. Alle anderen Sitzungen wurden abgemeldet.",
|
|
||||||
});
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setRepeat("");
|
|
||||||
} catch (caught) {
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard
|
|
||||||
title={hasPassword ? "Passwort aendern" : "Passwort vergeben"}
|
|
||||||
icon={KeyRound}
|
|
||||||
description={
|
|
||||||
hasPassword
|
|
||||||
? "Nach der Aenderung werden alle anderen Sitzungen beendet."
|
|
||||||
: "Dieses Konto hat bisher kein Passwort und nutzt nur Passkeys."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form onSubmit={save} className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{hasPassword ? (
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<label className="field-label" htmlFor="account-current">
|
|
||||||
Aktuelles Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-current"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="account-new">
|
|
||||||
Neues Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-new"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(event) => setNewPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={PASSWORD_MIN_LENGTH}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="account-repeat">
|
|
||||||
Wiederholen
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="account-repeat"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={repeat}
|
|
||||||
onChange={(event) => setRepeat(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <KeyRound className="h-4 w-4" />}
|
|
||||||
{hasPassword ? "Passwort aendern" : "Passwort setzen"}
|
|
||||||
</button>
|
|
||||||
<NoticeLine notice={notice} />
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PasskeySection({
|
|
||||||
passkeys,
|
|
||||||
available,
|
|
||||||
}: {
|
|
||||||
passkeys: PasskeyEntry[];
|
|
||||||
available: boolean;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
async function addPasskey() {
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const optionsResponse = await fetch("/api/auth/passkey/register/options", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
|
||||||
if (!optionsResponse.ok) {
|
|
||||||
throw new Error(optionsPayload.error ?? "Passkey konnte nicht vorbereitet werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const attestation = await startRegistration({ optionsJSON: optionsPayload.options });
|
|
||||||
|
|
||||||
const suggestion =
|
|
||||||
typeof navigator !== "undefined" && /mac/i.test(navigator.platform)
|
|
||||||
? "Mac"
|
|
||||||
: typeof navigator !== "undefined" && /win/i.test(navigator.platform)
|
|
||||||
? "Windows"
|
|
||||||
: "Geraet";
|
|
||||||
|
|
||||||
const verifyResponse = await fetch("/api/auth/passkey/register/verify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ response: attestation, name: suggestion }),
|
|
||||||
});
|
|
||||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
|
||||||
if (!verifyResponse.ok) {
|
|
||||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
|
||||||
}
|
|
||||||
|
|
||||||
setNotice({ ok: true, text: "Passkey hinzugefuegt." });
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
const message = caught instanceof Error ? caught.message : String(caught);
|
|
||||||
if (!/abort|NotAllowed/i.test(message)) setNotice({ ok: false, text: message });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rename(entry: PasskeyEntry) {
|
|
||||||
const name = window.prompt("Name des Passkeys", entry.name ?? "");
|
|
||||||
if (name === null) return;
|
|
||||||
setBusyId(entry.id);
|
|
||||||
await fetch(`/api/account/passkeys/${entry.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name }),
|
|
||||||
}).catch(() => undefined);
|
|
||||||
setBusyId(null);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(entry: PasskeyEntry) {
|
|
||||||
if (!window.confirm(`Passkey „${entry.name ?? "ohne Namen"}“ entfernen?`)) return;
|
|
||||||
setBusyId(entry.id);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/account/passkeys/${entry.id}`, { method: "DELETE" });
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusyId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard
|
|
||||||
title="Passkeys"
|
|
||||||
icon={Fingerprint}
|
|
||||||
description="Anmeldung per Fingerabdruck, Gesichtserkennung oder Sicherheitsschluessel – ohne Passwort."
|
|
||||||
>
|
|
||||||
{!available ? (
|
|
||||||
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
|
|
||||||
Passkeys sind derzeit nicht verfuegbar. Sie benoetigen einen sicheren Kontext:
|
|
||||||
<code className="mx-1 text-violet-300">APP_URL</code> muss auf eine https-Adresse
|
|
||||||
oder auf http://localhost zeigen.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{passkeys.length === 0 ? (
|
|
||||||
<p className="text-sm text-slate-500">Noch kein Passkey hinterlegt.</p>
|
|
||||||
) : (
|
|
||||||
passkeys.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="glass-soft flex items-center justify-between gap-3 px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="flex items-center gap-2 truncate text-sm text-slate-200">
|
|
||||||
<Fingerprint className="h-3.5 w-3.5 shrink-0 text-violet-300" />
|
|
||||||
{entry.name ?? "Unbenannter Passkey"}
|
|
||||||
{entry.backedUp ? (
|
|
||||||
<span className="rounded bg-emerald-500/12 px-1.5 py-0.5 text-[10px] text-emerald-200">
|
|
||||||
synchronisiert
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
|
||||||
angelegt {formatDateTime(entry.createdAt)}
|
|
||||||
{entry.lastUsedAt
|
|
||||||
? ` · zuletzt genutzt ${formatRelative(entry.lastUsedAt)}`
|
|
||||||
: " · noch nie genutzt"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
{busyId === entry.id ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-slate-500" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => rename(entry)}
|
|
||||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
|
|
||||||
aria-label="Umbenennen"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(entry)}
|
|
||||||
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
|
|
||||||
aria-label="Entfernen"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className="btn btn-ghost mt-4" onClick={addPasskey} disabled={busy}>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Fingerprint className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Passkey hinzufuegen
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NoticeLine notice={notice} />
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionSection({ sessions }: { sessions: SessionEntry[] }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
async function logoutEverywhere() {
|
|
||||||
if (!window.confirm("Auf allen anderen Geraeten abmelden?")) return;
|
|
||||||
setBusy(true);
|
|
||||||
await fetch("/api/account/sessions", { method: "DELETE" }).catch(() => undefined);
|
|
||||||
setBusy(false);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionCard
|
|
||||||
title="Aktive Sitzungen"
|
|
||||||
icon={Monitor}
|
|
||||||
description="Jede Anmeldung erzeugt eine Sitzung. Sie laeuft nach 30 Tagen ohne Nutzung ab."
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{sessions.map((session) => (
|
|
||||||
<div key={session.id} className="glass-soft px-3 py-2.5">
|
|
||||||
<p className="truncate text-sm text-slate-300">
|
|
||||||
{shortenUserAgent(session.userAgent)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-[11px] text-slate-500" suppressHydrationWarning>
|
|
||||||
{session.ip ?? "unbekannte Adresse"} · aktiv {formatRelative(session.lastSeenAt)} ·
|
|
||||||
angemeldet {formatDateTime(session.createdAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-danger mt-4"
|
|
||||||
onClick={logoutEverywhere}
|
|
||||||
disabled={busy || sessions.length <= 1}
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4" /> : <LogOut className="h-4 w-4" />}
|
|
||||||
Ueberall abmelden
|
|
||||||
</button>
|
|
||||||
{sessions.length <= 1 ? (
|
|
||||||
<p className="mt-2 flex items-center gap-1.5 text-[11px] text-slate-600">
|
|
||||||
<ShieldCheck className="h-3 w-3" />
|
|
||||||
Nur dieses Geraet ist angemeldet.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</SectionCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Aus dem User-Agent etwas Lesbares machen, ohne Bibliothek. */
|
|
||||||
function shortenUserAgent(userAgent: string | null): string {
|
|
||||||
if (!userAgent) return "Unbekanntes Geraet";
|
|
||||||
|
|
||||||
const browser =
|
|
||||||
/Edg\//.test(userAgent) ? "Edge"
|
|
||||||
: /OPR\//.test(userAgent) ? "Opera"
|
|
||||||
: /Firefox\//.test(userAgent) ? "Firefox"
|
|
||||||
: /Chrome\//.test(userAgent) ? "Chrome"
|
|
||||||
: /Safari\//.test(userAgent) ? "Safari"
|
|
||||||
: "Browser";
|
|
||||||
|
|
||||||
const platform =
|
|
||||||
/Windows/.test(userAgent) ? "Windows"
|
|
||||||
: /Macintosh|Mac OS/.test(userAgent) ? "macOS"
|
|
||||||
: /Android/.test(userAgent) ? "Android"
|
|
||||||
: /iPhone|iPad/.test(userAgent) ? "iOS"
|
|
||||||
: /Linux/.test(userAgent) ? "Linux"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return platform ? `${browser} auf ${platform}` : browser;
|
|
||||||
}
|
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Ban,
|
|
||||||
CheckCircle2,
|
|
||||||
Fingerprint,
|
|
||||||
FolderGit2,
|
|
||||||
Loader2,
|
|
||||||
Lock,
|
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
UserCog,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Modal } from "@/components/ui/Modal";
|
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
|
||||||
import { cn, formatDateTime, formatRelative } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
|
||||||
type Role = "ADMIN" | "USER";
|
|
||||||
|
|
||||||
export interface AdminUser {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string | null;
|
|
||||||
email: string | null;
|
|
||||||
role: Role;
|
|
||||||
isActive: boolean;
|
|
||||||
lastLoginAt: string | Date | null;
|
|
||||||
lockedUntil: string | Date | null;
|
|
||||||
createdAt: string | Date;
|
|
||||||
_count: { credentials: number; projects: number; sessions: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AutoSyncReport {
|
|
||||||
at: string;
|
|
||||||
trigger: "zeitplan" | "manuell";
|
|
||||||
candidates: number;
|
|
||||||
synced: number;
|
|
||||||
failed: number;
|
|
||||||
skipped: number;
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminData {
|
|
||||||
mode: Mode;
|
|
||||||
allowRegistration: boolean;
|
|
||||||
users: AdminUser[];
|
|
||||||
currentUserId: string;
|
|
||||||
appUrl: string;
|
|
||||||
passkeysAvailable: boolean;
|
|
||||||
autoSync: {
|
|
||||||
enabled: boolean;
|
|
||||||
intervalMinutes: number;
|
|
||||||
activeOnly: boolean;
|
|
||||||
batchSize: number;
|
|
||||||
lastRunAt: string | Date | null;
|
|
||||||
lastReport: AutoSyncReport | null;
|
|
||||||
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
|
|
||||||
eligible: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type Notice = { ok: boolean; text: string } | null;
|
|
||||||
|
|
||||||
export function AdminManager({ data }: { data: AdminData }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [mode, setMode] = useState<Mode>(data.mode);
|
|
||||||
const [allowRegistration, setAllowRegistration] = useState(data.allowRegistration);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
|
||||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
|
||||||
|
|
||||||
async function patchSettings(payload: Partial<{ mode: Mode; allowRegistration: boolean }>) {
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/settings", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
|
||||||
|
|
||||||
setMode(body.settings.mode);
|
|
||||||
setAllowRegistration(body.settings.allowRegistration);
|
|
||||||
setNotice({ ok: true, text: "Einstellungen gespeichert." });
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
// Umschalter zuruecksetzen, wenn der Server ablehnt.
|
|
||||||
setMode(data.mode);
|
|
||||||
setAllowRegistration(data.allowRegistration);
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* ── Betriebsart ─────────────────────────────────── */}
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
|
|
||||||
<Users className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Betriebsart
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Bestimmt, ob die Instanz nur dir gehoert oder mehrere Konten verwaltet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<ModeCard
|
|
||||||
active={mode === "SINGLE_USER"}
|
|
||||||
disabled={busy}
|
|
||||||
icon={User}
|
|
||||||
title="Einzelbetrieb"
|
|
||||||
hint="Genau ein Konto. Keine Registrierung, keine Benutzerverwaltung."
|
|
||||||
onClick={() => {
|
|
||||||
setMode("SINGLE_USER");
|
|
||||||
void patchSettings({ mode: "SINGLE_USER" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ModeCard
|
|
||||||
active={mode === "MULTI_USER"}
|
|
||||||
disabled={busy}
|
|
||||||
icon={Users}
|
|
||||||
title="Mehrbenutzerbetrieb"
|
|
||||||
hint="Mehrere Konten. Projekte, Notizen und Git-Verbindungen bleiben streng getrennt."
|
|
||||||
onClick={() => {
|
|
||||||
setMode("MULTI_USER");
|
|
||||||
void patchSettings({ mode: "MULTI_USER" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "MULTI_USER" ? (
|
|
||||||
<label className="mt-4 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allowRegistration}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setAllowRegistration(event.target.checked);
|
|
||||||
void patchSettings({ allowRegistration: event.target.checked });
|
|
||||||
}}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm text-slate-200">Selbstregistrierung erlauben</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Ist das aus, legt nur ein Administrator neue Konten an. Bei einer aus dem
|
|
||||||
Internet erreichbaren Instanz besser aus lassen.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
|
||||||
notice.ok
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
|
||||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notice.text}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<AutoSyncSection data={data.autoSync} />
|
|
||||||
|
|
||||||
{/* ── Instanz ─────────────────────────────────────── */}
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
|
||||||
<ShieldCheck className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Instanz
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Diese Werte kommen aus der <code className="text-violet-300">.env</code> und
|
|
||||||
lassen sich nur dort aendern.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">APP_URL</dt>
|
|
||||||
<dd className="mt-1 truncate font-mono text-sm text-slate-200">{data.appUrl}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">Passkeys</dt>
|
|
||||||
<dd className="mt-1 flex items-center gap-1.5 text-sm text-slate-200">
|
|
||||||
{data.passkeysAvailable ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-300" />
|
|
||||||
verfuegbar
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Ban className="h-3.5 w-3.5 text-amber-300" />
|
|
||||||
kein sicherer Kontext
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── Benutzer ────────────────────────────────────── */}
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-emerald-500/12 text-emerald-300">
|
|
||||||
<UserCog className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Benutzer ({data.users.length})
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Administratoren verwalten Konten – Projekte anderer sehen sie nicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary px-3 py-1.5 text-xs"
|
|
||||||
onClick={() => setCreateOpen(true)}
|
|
||||||
disabled={mode === "SINGLE_USER"}
|
|
||||||
title={
|
|
||||||
mode === "SINGLE_USER"
|
|
||||||
? "Erst in den Mehrbenutzerbetrieb wechseln"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
Konto anlegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.users.map((user) => (
|
|
||||||
<UserRow
|
|
||||||
key={user.id}
|
|
||||||
user={user}
|
|
||||||
isSelf={user.id === data.currentUserId}
|
|
||||||
onEdit={() => setEditing(user)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<UserDialog
|
|
||||||
open={createOpen}
|
|
||||||
mode="create"
|
|
||||||
onClose={() => setCreateOpen(false)}
|
|
||||||
/>
|
|
||||||
<UserDialog
|
|
||||||
open={editing !== null}
|
|
||||||
mode="edit"
|
|
||||||
user={editing}
|
|
||||||
isSelf={editing?.id === data.currentUserId}
|
|
||||||
onClose={() => setEditing(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const INTERVALS = [
|
|
||||||
{ value: 15, label: "alle 15 Minuten" },
|
|
||||||
{ value: 30, label: "alle 30 Minuten" },
|
|
||||||
{ value: 60, label: "stuendlich" },
|
|
||||||
{ value: 180, label: "alle 3 Stunden" },
|
|
||||||
{ value: 360, label: "alle 6 Stunden" },
|
|
||||||
{ value: 720, label: "alle 12 Stunden" },
|
|
||||||
{ value: 1440, label: "taeglich" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [enabled, setEnabled] = useState(data.enabled);
|
|
||||||
const [interval, setIntervalMinutes] = useState(data.intervalMinutes);
|
|
||||||
const [activeOnly, setActiveOnly] = useState(data.activeOnly);
|
|
||||||
const [batchSize, setBatchSize] = useState(data.batchSize);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [notice, setNotice] = useState<Notice>(null);
|
|
||||||
|
|
||||||
async function patch(payload: Record<string, unknown>) {
|
|
||||||
setBusy(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/settings", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
|
|
||||||
setNotice({ ok: true, text: "Gespeichert." });
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setEnabled(data.enabled);
|
|
||||||
setIntervalMinutes(data.intervalMinutes);
|
|
||||||
setActiveOnly(data.activeOnly);
|
|
||||||
setBatchSize(data.batchSize);
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runNow() {
|
|
||||||
setRunning(true);
|
|
||||||
setNotice(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/sync/run", { method: "POST" });
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen.");
|
|
||||||
const report = body.report as AutoSyncReport;
|
|
||||||
setNotice({
|
|
||||||
ok: true,
|
|
||||||
text:
|
|
||||||
`${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` +
|
|
||||||
`${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`,
|
|
||||||
});
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextRun =
|
|
||||||
enabled && data.lastRunAt
|
|
||||||
? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="glass p-5">
|
|
||||||
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
|
|
||||||
Automatischer Abgleich
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1 max-w-xl text-sm text-slate-400">
|
|
||||||
Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle
|
|
||||||
Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was
|
|
||||||
ohnehin verknuepft ist.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost px-3 py-1.5 text-xs"
|
|
||||||
onClick={runNow}
|
|
||||||
disabled={running || data.eligible === 0}
|
|
||||||
title={data.eligible === 0 ? "Kein Projekt mit Repository verknuepft" : undefined}
|
|
||||||
>
|
|
||||||
{running ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Jetzt abgleichen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setEnabled(event.target.checked);
|
|
||||||
void patch({ autoSyncEnabled: event.target.checked });
|
|
||||||
}}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm text-slate-200">Regelmaessig abgleichen</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Standardmaessig aus. Jeder Durchlauf verbraucht Kontingent bei GitHub,
|
|
||||||
GitLab und Gitea – ohne Token ist das schnell erschoepft.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{enabled ? (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="autosync-interval">
|
|
||||||
Abstand
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="autosync-interval"
|
|
||||||
className="field"
|
|
||||||
value={interval}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = Number(event.target.value);
|
|
||||||
setIntervalMinutes(value);
|
|
||||||
void patch({ autoSyncIntervalMinutes: value });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{INTERVALS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="autosync-batch">
|
|
||||||
Projekte je Durchlauf: {batchSize}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="autosync-batch"
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={batchSize}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => setBatchSize(Number(event.target.value))}
|
|
||||||
onMouseUp={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
onKeyUp={() => patch({ autoSyncBatchSize: batchSize })}
|
|
||||||
className="range mt-2"
|
|
||||||
style={{ "--fill": `${batchSize}%` } as React.CSSProperties}
|
|
||||||
/>
|
|
||||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
|
||||||
Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="mt-3 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={activeOnly}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(event) => {
|
|
||||||
setActiveOnly(event.target.checked);
|
|
||||||
void patch({ autoSyncActiveOnly: event.target.checked });
|
|
||||||
}}
|
|
||||||
className="mt-0.5 h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block text-sm text-slate-200">
|
|
||||||
Fertige und archivierte Projekte auslassen
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-[11px] text-slate-500">
|
|
||||||
Dort aendert sich selten noch etwas.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Zustand */}
|
|
||||||
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Verknuepfte Projekte
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.eligible}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Letzter Durchlauf
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
|
||||||
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="glass-soft px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
|
|
||||||
Naechster Durchlauf
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
|
|
||||||
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{data.lastReport ? (
|
|
||||||
<p className="mt-3 text-[11px] text-slate-500" suppressHydrationWarning>
|
|
||||||
Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "}
|
|
||||||
{data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt
|
|
||||||
in {Math.round(data.lastReport.durationMs / 1000)} s.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-3 rounded-lg border px-3 py-2 text-sm",
|
|
||||||
notice.ok
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
|
||||||
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notice.text}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModeCard({
|
|
||||||
active,
|
|
||||||
disabled,
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
hint,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
disabled: boolean;
|
|
||||||
icon: typeof User;
|
|
||||||
title: string;
|
|
||||||
hint: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border px-4 py-3.5 text-left transition-all disabled:opacity-60",
|
|
||||||
active
|
|
||||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_28px_-12px_rgba(139,92,246,0.9)]"
|
|
||||||
: "border-white/9 hover:border-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
|
||||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
|
||||||
{title}
|
|
||||||
{active ? (
|
|
||||||
<CheckCircle2 className="ml-auto h-4 w-4 text-violet-300" />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1.5 block text-[11px] leading-snug text-slate-500">{hint}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserRow({
|
|
||||||
user,
|
|
||||||
isSelf,
|
|
||||||
onEdit,
|
|
||||||
}: {
|
|
||||||
user: AdminUser;
|
|
||||||
isSelf: boolean;
|
|
||||||
onEdit: () => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const locked =
|
|
||||||
user.lockedUntil !== null && new Date(user.lockedUntil).getTime() > Date.now();
|
|
||||||
|
|
||||||
async function remove() {
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
`Konto „${user.username}“ mit ${user._count.projects} Projekt(en) unwiderruflich loeschen?`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Loeschen fehlgeschlagen.");
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="glass-soft px-3 py-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"grid h-9 w-9 shrink-0 place-items-center rounded-lg text-[11px] font-semibold text-white",
|
|
||||||
user.isActive
|
|
||||||
? "bg-gradient-to-br from-violet-500/80 to-cyan-400/70"
|
|
||||||
: "bg-slate-700/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(user.displayName ?? user.username).slice(0, 2).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
|
|
||||||
<span className="truncate">{user.displayName ?? user.username}</span>
|
|
||||||
<span className="font-mono text-[11px] text-slate-500">@{user.username}</span>
|
|
||||||
{user.role === "ADMIN" ? (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-violet-500/12 px-1.5 py-0.5 text-[10px] text-violet-200">
|
|
||||||
<ShieldCheck className="h-2.5 w-2.5" />
|
|
||||||
Admin
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{isSelf ? (
|
|
||||||
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
|
|
||||||
du
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{!user.isActive ? (
|
|
||||||
<span className="rounded bg-rose-500/12 px-1.5 py-0.5 text-[10px] text-rose-200">
|
|
||||||
deaktiviert
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{locked ? (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] text-amber-200">
|
|
||||||
<Lock className="h-2.5 w-2.5" />
|
|
||||||
gesperrt
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500">
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<FolderGit2 className="h-3 w-3" />
|
|
||||||
{user._count.projects} Projekte
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<Fingerprint className="h-3 w-3" />
|
|
||||||
{user._count.credentials} Passkeys
|
|
||||||
</span>
|
|
||||||
<span suppressHydrationWarning>
|
|
||||||
{user.lastLoginAt
|
|
||||||
? `zuletzt ${formatRelative(user.lastLoginAt)}`
|
|
||||||
: "noch nie angemeldet"}
|
|
||||||
</span>
|
|
||||||
<span suppressHydrationWarning>angelegt {formatDateTime(user.createdAt)}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEdit}
|
|
||||||
className="btn btn-ghost px-2.5 py-1.5 text-xs"
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
<UserCog className="h-3.5 w-3.5" />
|
|
||||||
Bearbeiten
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={remove}
|
|
||||||
className="btn btn-danger px-2.5 py-1.5 text-xs"
|
|
||||||
disabled={busy || isSelf}
|
|
||||||
title={isSelf ? "Das eigene Konto kann nicht geloescht werden" : "Loeschen"}
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserDialog({
|
|
||||||
open,
|
|
||||||
mode,
|
|
||||||
user,
|
|
||||||
isSelf,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
mode: "create" | "edit";
|
|
||||||
user?: AdminUser | null;
|
|
||||||
isSelf?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [role, setRole] = useState<Role>("USER");
|
|
||||||
const [isActive, setIsActive] = useState(true);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Formular beim Oeffnen einmalig aus dem Datensatz fuellen.
|
|
||||||
const key = open ? (user?.id ?? "new") : null;
|
|
||||||
if (key !== null && key !== loadedFor) {
|
|
||||||
setLoadedFor(key);
|
|
||||||
setUsername(user?.username ?? "");
|
|
||||||
setDisplayName(user?.displayName ?? "");
|
|
||||||
setEmail(user?.email ?? "");
|
|
||||||
setPassword("");
|
|
||||||
setRole(user?.role ?? "USER");
|
|
||||||
setIsActive(user?.isActive ?? true);
|
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
const body =
|
|
||||||
mode === "create"
|
|
||||||
? { username, displayName, email, password, role }
|
|
||||||
: { displayName, email, role, isActive, password: password || null };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
mode === "create" ? "/api/admin/users" : `/api/admin/users/${user?.id}`,
|
|
||||||
{
|
|
||||||
method: mode === "create" ? "POST" : "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
setFieldErrors(payload.fields ?? {});
|
|
||||||
throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
setLoadedFor(null);
|
|
||||||
onClose();
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onClose={() => {
|
|
||||||
setLoadedFor(null);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
title={mode === "create" ? "Neues Konto" : `Konto bearbeiten`}
|
|
||||||
description={
|
|
||||||
mode === "create"
|
|
||||||
? "Das Konto kann sich sofort anmelden und einen eigenen Passkey hinterlegen."
|
|
||||||
: "Ein neues Passwort beendet alle Sitzungen dieses Kontos."
|
|
||||||
}
|
|
||||||
width="md"
|
|
||||||
>
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
{mode === "create" ? (
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="admin-username">
|
|
||||||
Benutzername *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="admin-username"
|
|
||||||
className="field"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{fieldErrors.username ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="font-mono text-sm text-slate-400">@{user?.username}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="admin-display">
|
|
||||||
Anzeigename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="admin-display"
|
|
||||||
className="field"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="admin-email">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="admin-email"
|
|
||||||
className="field"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
/>
|
|
||||||
{fieldErrors.email ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="admin-password">
|
|
||||||
{mode === "create" ? "Passwort *" : "Neues Passwort (leer = unveraendert)"}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="admin-password"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
minLength={mode === "create" ? PASSWORD_MIN_LENGTH : undefined}
|
|
||||||
required={mode === "create"}
|
|
||||||
/>
|
|
||||||
{fieldErrors.password ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span className="field-label">Rolle</span>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{(["USER", "ADMIN"] as Role[]).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRole(value)}
|
|
||||||
disabled={isSelf && value === "USER"}
|
|
||||||
title={
|
|
||||||
isSelf && value === "USER"
|
|
||||||
? "Die eigene Administratorrolle kann hier nicht abgegeben werden"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
|
|
||||||
role === value
|
|
||||||
? "border-violet-400/60 bg-violet-500/12 text-white"
|
|
||||||
: "border-white/9 text-slate-400 hover:border-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value === "ADMIN" ? "Administrator" : "Benutzer"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mode === "edit" ? (
|
|
||||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-400">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isActive}
|
|
||||||
onChange={(event) => setIsActive(event.target.checked)}
|
|
||||||
disabled={isSelf}
|
|
||||||
className="h-4 w-4 accent-violet-500"
|
|
||||||
/>
|
|
||||||
Konto aktiv
|
|
||||||
{isSelf ? (
|
|
||||||
<span className="text-[11px] text-slate-600">
|
|
||||||
(das eigene Konto laesst sich nicht deaktivieren)
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</label>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setLoadedFor(null);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
/** Zentrierte Karte fuer Einrichtung, Anmeldung und Registrierung. */
|
|
||||||
export function AuthShell({
|
|
||||||
appName,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
}: {
|
|
||||||
appName: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
footer?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[70vh] w-full max-w-md flex-col justify-center py-10">
|
|
||||||
<div className="glass fade-up relative overflow-hidden p-7">
|
|
||||||
<span className="accent-bar absolute inset-x-0 top-0 h-[3px]" aria-hidden="true" />
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute -right-24 -top-24 h-56 w-56 rounded-full opacity-25 blur-3xl"
|
|
||||||
style={{ background: "radial-gradient(circle, #8b5cf6, transparent 70%)" }}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
|
||||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400">
|
|
||||||
<Sparkles className="h-5 w-5 text-white" strokeWidth={2.4} />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-gradient text-lg font-semibold leading-none">{appName}</p>
|
|
||||||
<p className="mt-1 text-[10px] uppercase tracking-[0.2em] text-slate-500">
|
|
||||||
Vibe Coding Dashboard
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-xl font-semibold text-white">{title}</h1>
|
|
||||||
{subtitle ? <p className="mt-1.5 text-sm text-slate-400">{subtitle}</p> : null}
|
|
||||||
|
|
||||||
<div className="mt-6">{children}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{footer ? <div className="mt-4 text-center text-sm text-slate-500">{footer}</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { startAuthentication } from "@simplewebauthn/browser";
|
|
||||||
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react";
|
|
||||||
|
|
||||||
export function LoginForm({
|
|
||||||
passkeysAvailable,
|
|
||||||
registrationOpen,
|
|
||||||
next,
|
|
||||||
}: {
|
|
||||||
passkeysAvailable: boolean;
|
|
||||||
registrationOpen: boolean;
|
|
||||||
next: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [busy, setBusy] = useState<"password" | "passkey" | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function done() {
|
|
||||||
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
|
|
||||||
router.replace(next);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithPassword(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setBusy("password");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
|
|
||||||
done();
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginWithPasskey() {
|
|
||||||
setBusy("passkey");
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const optionsResponse = await fetch("/api/auth/passkey/login/options", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
const optionsPayload = await optionsResponse.json().catch(() => ({}));
|
|
||||||
if (!optionsResponse.ok) {
|
|
||||||
throw new Error(optionsPayload.error ?? "Passkey-Anmeldung nicht moeglich.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const assertion = await startAuthentication({
|
|
||||||
optionsJSON: optionsPayload.options,
|
|
||||||
});
|
|
||||||
|
|
||||||
const verifyResponse = await fetch("/api/auth/passkey/login/verify", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ response: assertion }),
|
|
||||||
});
|
|
||||||
const verifyPayload = await verifyResponse.json().catch(() => ({}));
|
|
||||||
if (!verifyResponse.ok) {
|
|
||||||
throw new Error(verifyPayload.error ?? "Passkey wurde nicht akzeptiert.");
|
|
||||||
}
|
|
||||||
done();
|
|
||||||
} catch (caught) {
|
|
||||||
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
|
||||||
const message = caught instanceof Error ? caught.message : String(caught);
|
|
||||||
setError(/abort|NotAllowed/i.test(message) ? null : message);
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{passkeysAvailable ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={loginWithPasskey}
|
|
||||||
disabled={busy !== null}
|
|
||||||
className="btn btn-ghost w-full py-2.5"
|
|
||||||
>
|
|
||||||
{busy === "passkey" ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Fingerprint className="h-4 w-4 text-violet-300" />
|
|
||||||
)}
|
|
||||||
Mit Passkey anmelden
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-[11px] uppercase tracking-wider text-slate-600">
|
|
||||||
<span className="h-px flex-1 bg-white/8" />
|
|
||||||
oder
|
|
||||||
<span className="h-px flex-1 bg-white/8" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<form onSubmit={loginWithPassword} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="login-username">
|
|
||||||
Benutzername
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-username"
|
|
||||||
className="field"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoComplete="username webauthn"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="login-password">
|
|
||||||
Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login-password"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy !== null}>
|
|
||||||
{busy === "password" ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<LogIn className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{registrationOpen ? (
|
|
||||||
<p className="text-center text-sm text-slate-500">
|
|
||||||
Noch kein Konto?{" "}
|
|
||||||
<Link href="/register" className="text-violet-300 hover:text-violet-200">
|
|
||||||
Registrieren
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!passkeysAvailable ? (
|
|
||||||
<p className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-[11px] leading-snug text-slate-500">
|
|
||||||
<KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>
|
|
||||||
Passkeys sind deaktiviert: Sie brauchen einen sicheren Kontext. Dafuer muss
|
|
||||||
<code className="mx-1 text-violet-300">APP_URL</code>
|
|
||||||
auf eine https-Adresse oder auf http://localhost zeigen.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Loader2, UserPlus } from "lucide-react";
|
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
|
||||||
|
|
||||||
export function RegisterForm() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [repeat, setRepeat] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
if (password !== repeat) {
|
|
||||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, displayName, email, password }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
setFieldErrors(payload.fields ?? {});
|
|
||||||
throw new Error(payload.error ?? "Registrierung fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
router.replace("/");
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="register-username">
|
|
||||||
Benutzername *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-username"
|
|
||||||
className="field"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{fieldErrors.username ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="register-display">
|
|
||||||
Anzeigename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-display"
|
|
||||||
className="field"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="register-email">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-email"
|
|
||||||
className="field"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="optional"
|
|
||||||
/>
|
|
||||||
{fieldErrors.email ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="register-password">
|
|
||||||
Passwort *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-password"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
minLength={PASSWORD_MIN_LENGTH}
|
|
||||||
/>
|
|
||||||
{fieldErrors.password ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="register-repeat">
|
|
||||||
Passwort wiederholen *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="register-repeat"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={repeat}
|
|
||||||
onChange={(event) => setRepeat(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{fieldErrors.repeat ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserPlus className="h-4 w-4" />}
|
|
||||||
Konto anlegen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-slate-500">
|
|
||||||
Schon ein Konto?{" "}
|
|
||||||
<Link href="/login" className="text-violet-300 hover:text-violet-200">
|
|
||||||
Anmelden
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Loader2, Rocket, User, Users } from "lucide-react";
|
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mode = "SINGLE_USER" | "MULTI_USER";
|
|
||||||
|
|
||||||
const MODES: Array<{ value: Mode; label: string; hint: string; icon: typeof User }> = [
|
|
||||||
{
|
|
||||||
value: "SINGLE_USER",
|
|
||||||
label: "Nur ich",
|
|
||||||
hint: "Ein Konto, keine Registrierung, keine Benutzerliste.",
|
|
||||||
icon: User,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "MULTI_USER",
|
|
||||||
label: "Mehrere Personen",
|
|
||||||
hint: "Weitere Konten moeglich, jedes mit streng getrennten Daten.",
|
|
||||||
icon: Users,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SetupForm() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [repeat, setRepeat] = useState("");
|
|
||||||
const [mode, setMode] = useState<Mode>("SINGLE_USER");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
async function submit(event: React.FormEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
if (password !== repeat) {
|
|
||||||
setFieldErrors({ repeat: "Die Passwoerter stimmen nicht ueberein." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/setup", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ username, displayName, email, password, mode }),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
setFieldErrors(payload.fields ?? {});
|
|
||||||
throw new Error(payload.error ?? "Einrichtung fehlgeschlagen.");
|
|
||||||
}
|
|
||||||
router.replace("/");
|
|
||||||
router.refresh();
|
|
||||||
} catch (caught) {
|
|
||||||
setError(caught instanceof Error ? caught.message : String(caught));
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submit} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<span className="field-label">Betriebsart</span>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
{MODES.map((option) => {
|
|
||||||
const Icon = option.icon;
|
|
||||||
const active = mode === option.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode(option.value)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl border px-3 py-3 text-left transition-all",
|
|
||||||
active
|
|
||||||
? "border-violet-400/60 bg-violet-500/12 shadow-[0_0_24px_-10px_rgba(139,92,246,0.9)]"
|
|
||||||
: "border-white/9 hover:border-white/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
|
||||||
<Icon className={cn("h-4 w-4", active ? "text-violet-300" : "text-slate-500")} />
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-[11px] leading-snug text-slate-500">
|
|
||||||
{option.hint}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1.5 text-[11px] text-slate-500">
|
|
||||||
Laesst sich spaeter in den Admin-Einstellungen umstellen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="setup-username">
|
|
||||||
Benutzername *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-username"
|
|
||||||
className="field"
|
|
||||||
value={username}
|
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
|
||||||
autoComplete="username"
|
|
||||||
autoCapitalize="none"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="florian"
|
|
||||||
required
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{fieldErrors.username ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.username}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="setup-display">
|
|
||||||
Anzeigename
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-display"
|
|
||||||
className="field"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
|
||||||
placeholder="Florian"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="setup-email">
|
|
||||||
E-Mail
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-email"
|
|
||||||
className="field"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="optional"
|
|
||||||
/>
|
|
||||||
{fieldErrors.email ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.email}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="setup-password">
|
|
||||||
Passwort *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-password"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
minLength={PASSWORD_MIN_LENGTH}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500">
|
|
||||||
Mindestens {PASSWORD_MIN_LENGTH} Zeichen. Einen Passkey kannst du danach unter
|
|
||||||
„Mein Konto“ hinzufuegen.
|
|
||||||
</p>
|
|
||||||
{fieldErrors.password ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.password}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="field-label" htmlFor="setup-repeat">
|
|
||||||
Passwort wiederholen *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="setup-repeat"
|
|
||||||
className="field"
|
|
||||||
type="password"
|
|
||||||
value={repeat}
|
|
||||||
onChange={(event) => setRepeat(event.target.value)}
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{fieldErrors.repeat ? (
|
|
||||||
<p className="mt-1 text-xs text-rose-300">{fieldErrors.repeat}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button type="submit" className="btn btn-primary w-full py-2.5" disabled={busy}>
|
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Rocket className="h-4 w-4" />}
|
|
||||||
Einrichten und loslegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dialog per Portal am body.
|
|
||||||
*
|
|
||||||
* Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten
|
|
||||||
* heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen
|
|
||||||
* Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am
|
|
||||||
* Fenster.
|
|
||||||
*/
|
|
||||||
export function Modal({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
footer,
|
|
||||||
width = "lg",
|
|
||||||
}: ModalProps) {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
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 (!mounted || !open) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Dialog schliessen"
|
|
||||||
onClick={onClose}
|
|
||||||
className="fixed inset-0 cursor-default bg-[#03040c]/85 backdrop-blur-sm"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={title}
|
|
||||||
className={cn(
|
|
||||||
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
|
|
||||||
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>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schwebendes Menue, das per Portal am body haengt.
|
|
||||||
*
|
|
||||||
* Warum nicht einfach ein absolut positioniertes Kind?
|
|
||||||
*
|
|
||||||
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
|
|
||||||
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
|
|
||||||
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
|
|
||||||
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
|
|
||||||
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
|
|
||||||
* selbst position: fixed bezieht sich auf die Karte statt auf das
|
|
||||||
* Fenster.
|
|
||||||
*
|
|
||||||
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
|
|
||||||
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
|
|
||||||
*/
|
|
||||||
export function Popover({
|
|
||||||
anchorRef,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
children,
|
|
||||||
align = "end",
|
|
||||||
width = 240,
|
|
||||||
className,
|
|
||||||
labelledBy,
|
|
||||||
role = "menu",
|
|
||||||
}: {
|
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
/** "end" richtet die rechte Kante am Anker aus. */
|
|
||||||
align?: "start" | "end";
|
|
||||||
width?: number;
|
|
||||||
className?: string;
|
|
||||||
labelledBy?: string;
|
|
||||||
role?: "menu" | "listbox" | "dialog";
|
|
||||||
}) {
|
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
|
|
||||||
const place = useCallback(() => {
|
|
||||||
const anchor = anchorRef.current;
|
|
||||||
if (!anchor) return;
|
|
||||||
|
|
||||||
const rect = anchor.getBoundingClientRect();
|
|
||||||
const margin = 8;
|
|
||||||
const gap = 6;
|
|
||||||
|
|
||||||
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
|
|
||||||
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
|
|
||||||
// links stuende das Menue sonst quer ueber der Seite statt an seinem
|
|
||||||
// Ausloeser.
|
|
||||||
let left = align === "end" ? rect.right - width : rect.left;
|
|
||||||
if (left < margin) left = rect.left;
|
|
||||||
if (left + width > window.innerWidth - margin) left = rect.right - width;
|
|
||||||
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
|
|
||||||
|
|
||||||
const panelHeight = panelRef.current?.offsetHeight ?? 0;
|
|
||||||
let top = rect.bottom + gap;
|
|
||||||
|
|
||||||
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
|
|
||||||
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
|
|
||||||
const fitsAbove = rect.top - gap - panelHeight >= margin;
|
|
||||||
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
|
|
||||||
|
|
||||||
setPosition({ top, left });
|
|
||||||
}, [anchorRef, align, width]);
|
|
||||||
|
|
||||||
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
|
|
||||||
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
|
|
||||||
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setPosition(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
place();
|
|
||||||
}, [open, place]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
|
|
||||||
// capture: true, damit auch das Scrollen innerer Container ankommt.
|
|
||||||
const onScroll = () => place();
|
|
||||||
window.addEventListener("scroll", onScroll, true);
|
|
||||||
window.addEventListener("resize", onScroll);
|
|
||||||
|
|
||||||
function onPointerDown(event: MouseEvent) {
|
|
||||||
const target = event.target as Node;
|
|
||||||
if (panelRef.current?.contains(target)) return;
|
|
||||||
if (anchorRef.current?.contains(target)) return;
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", onPointerDown);
|
|
||||||
document.addEventListener("keydown", onKey);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", onScroll, true);
|
|
||||||
window.removeEventListener("resize", onScroll);
|
|
||||||
document.removeEventListener("mousedown", onPointerDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open, place, onClose, anchorRef]);
|
|
||||||
|
|
||||||
if (!mounted || !open) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
role={role}
|
|
||||||
aria-labelledby={labelledBy}
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: position?.top ?? -9999,
|
|
||||||
left: position?.left ?? -9999,
|
|
||||||
width,
|
|
||||||
// Bis die Position steht, unsichtbar halten – sonst blitzt das
|
|
||||||
// Menue kurz an der falschen Stelle auf.
|
|
||||||
visibility: position ? "visible" : "hidden",
|
|
||||||
}}
|
|
||||||
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wird von Next einmal beim Start des Servers ausgefuehrt.
|
|
||||||
*
|
|
||||||
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
|
|
||||||
*
|
|
||||||
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
|
|
||||||
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
|
|
||||||
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
|
|
||||||
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
|
|
||||||
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
|
|
||||||
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
|
|
||||||
* Build schluege fehl.
|
|
||||||
*/
|
|
||||||
export async function register() {
|
|
||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
|
||||||
const { describeConfig } = await import("@/lib/config");
|
|
||||||
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
|
|
||||||
|
|
||||||
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
|
|
||||||
startAutoSyncScheduler();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Konstanten, die Server und Browser gemeinsam brauchen.
|
|
||||||
*
|
|
||||||
* Bewusst in einer eigenen Datei ohne node:crypto-Import: password.ts laesst
|
|
||||||
* sich in Client-Komponenten nicht einbinden, diese Werte schon.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const PASSWORD_MIN_LENGTH = 10;
|
|
||||||
export const USERNAME_MIN_LENGTH = 3;
|
|
||||||
export const USERNAME_MAX_LENGTH = 32;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
|
|
||||||
import { getInstanceState } from "@/lib/auth/settings";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zugriffsschutz.
|
|
||||||
*
|
|
||||||
* Die Middleware prueft nur, ob ueberhaupt ein Sitzungscookie mitkommt – im
|
|
||||||
* Edge-Runtime steht keine Datenbank zur Verfuegung. Die eigentliche
|
|
||||||
* Entscheidung faellt hier, serverseitig, und zwar in jeder Seite und jedem
|
|
||||||
* API-Endpunkt. Das ist die Sicherheitsgrenze.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type { SessionUser };
|
|
||||||
|
|
||||||
/** Fuer Server Components: leitet auf /login bzw. /setup um. */
|
|
||||||
export async function requireUserPage(): Promise<SessionUser> {
|
|
||||||
const user = await getSessionUser();
|
|
||||||
if (user) return user;
|
|
||||||
|
|
||||||
const state = await getInstanceState();
|
|
||||||
redirect(state.needsSetup ? "/setup" : "/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wie requireUserPage, verlangt zusaetzlich die Administratorrolle. */
|
|
||||||
export async function requireAdminPage(): Promise<SessionUser> {
|
|
||||||
const user = await requireUserPage();
|
|
||||||
if (user.role !== "ADMIN") redirect("/");
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiAuth =
|
|
||||||
| { ok: true; user: SessionUser }
|
|
||||||
| { ok: false; response: NextResponse };
|
|
||||||
|
|
||||||
/** Fuer Route Handler: liefert entweder den Benutzer oder eine 401/403-Antwort. */
|
|
||||||
export async function requireUserApi(): Promise<ApiAuth> {
|
|
||||||
const user = await getSessionUser();
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ok: true, user };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdminApi(): Promise<ApiAuth> {
|
|
||||||
const auth = await requireUserApi();
|
|
||||||
if (!auth.ok) return auth;
|
|
||||||
if (auth.user.role !== "ADMIN") {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
response: NextResponse.json(
|
|
||||||
{ error: "Diese Aktion ist Administratoren vorbehalten." },
|
|
||||||
{ status: 403 },
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passwort-Hashing mit scrypt aus der Node-Standardbibliothek – kein
|
|
||||||
* zusaetzliches natives Modul noetig, und scrypt ist speicherhart, also
|
|
||||||
* deutlich unangenehmer fuer GPU-Angriffe als PBKDF2.
|
|
||||||
*
|
|
||||||
* Ablageformat: scrypt$<N>$<r>$<p>$<salt>$<hash> (salt/hash base64url)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback) as (
|
|
||||||
password: string | Buffer,
|
|
||||||
salt: string | Buffer,
|
|
||||||
keylen: number,
|
|
||||||
options: { N: number; r: number; p: number; maxmem: number },
|
|
||||||
) => Promise<Buffer>;
|
|
||||||
|
|
||||||
const PARAMS = { N: 32768, r: 8, p: 1 };
|
|
||||||
const KEY_LENGTH = 64;
|
|
||||||
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
|
|
||||||
// dafuer nicht ganz.
|
|
||||||
const MAX_MEM = 96 * 1024 * 1024;
|
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, {
|
|
||||||
...PARAMS,
|
|
||||||
maxmem: MAX_MEM,
|
|
||||||
});
|
|
||||||
return [
|
|
||||||
"scrypt",
|
|
||||||
PARAMS.N,
|
|
||||||
PARAMS.r,
|
|
||||||
PARAMS.p,
|
|
||||||
salt.toString("base64url"),
|
|
||||||
derived.toString("base64url"),
|
|
||||||
].join("$");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyPassword(
|
|
||||||
password: string,
|
|
||||||
stored: string | null | undefined,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!stored) return false;
|
|
||||||
|
|
||||||
const parts = stored.split("$");
|
|
||||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
|
||||||
|
|
||||||
const N = Number(parts[1]);
|
|
||||||
const r = Number(parts[2]);
|
|
||||||
const p = Number(parts[3]);
|
|
||||||
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
|
|
||||||
|
|
||||||
const salt = Buffer.from(parts[4], "base64url");
|
|
||||||
const expected = Buffer.from(parts[5], "base64url");
|
|
||||||
|
|
||||||
let derived: Buffer;
|
|
||||||
try {
|
|
||||||
derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
|
|
||||||
N,
|
|
||||||
r,
|
|
||||||
p,
|
|
||||||
maxmem: MAX_MEM,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (derived.length !== expected.length) return false;
|
|
||||||
return timingSafeEqual(derived, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
|
|
||||||
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
|
|
||||||
* Antwortzeit ablesen, welche Konten existieren.
|
|
||||||
*/
|
|
||||||
export async function fakeVerifyDelay(): Promise<void> {
|
|
||||||
await scrypt("nebula-dummy-password", "nebula-dummy-salt", KEY_LENGTH, {
|
|
||||||
...PARAMS,
|
|
||||||
maxmem: MAX_MEM,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const OBVIOUS = new Set([
|
|
||||||
"password", "passwort", "12345678", "123456789", "1234567890",
|
|
||||||
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
|
|
||||||
"willkommen", "nebula123", "geheim123",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export { PASSWORD_MIN_LENGTH };
|
|
||||||
|
|
||||||
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
|
|
||||||
export function validatePasswordStrength(password: string): string | null {
|
|
||||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
|
||||||
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
|
|
||||||
}
|
|
||||||
if (password.length > 200) {
|
|
||||||
return "Das Passwort darf hoechstens 200 Zeichen haben.";
|
|
||||||
}
|
|
||||||
if (OBVIOUS.has(password.toLowerCase())) {
|
|
||||||
return "Dieses Passwort ist zu naheliegend.";
|
|
||||||
}
|
|
||||||
if (/^\d+$/.test(password)) {
|
|
||||||
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import type { NextResponse } from "next/server";
|
|
||||||
import type { User } from "@prisma/client";
|
|
||||||
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serverseitige Sitzungen.
|
|
||||||
*
|
|
||||||
* Der Cookie enthaelt ein Zufallstoken, die Datenbank nur dessen SHA-256-Hash.
|
|
||||||
* Wer die Datenbank liest, kann damit keine Sitzung uebernehmen. Abmelden und
|
|
||||||
* Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird – anders
|
|
||||||
* als bei einem selbsttragenden Token.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function hashToken(token: string): string {
|
|
||||||
return createHash("sha256").update(token).digest("base64url");
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionUser {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
displayName: string | null;
|
|
||||||
email: string | null;
|
|
||||||
role: User["role"];
|
|
||||||
hasPassword: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSessionUser(user: User): SessionUser {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
displayName: user.displayName,
|
|
||||||
email: user.email,
|
|
||||||
role: user.role,
|
|
||||||
hasPassword: Boolean(user.passwordHash),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(
|
|
||||||
userId: string,
|
|
||||||
meta: { userAgent?: string | null; ip?: string | null } = {},
|
|
||||||
): Promise<{ token: string; expiresAt: Date }> {
|
|
||||||
const token = randomBytes(32).toString("base64url");
|
|
||||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
|
||||||
|
|
||||||
await prisma.session.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
tokenHash: hashToken(token),
|
|
||||||
expiresAt,
|
|
||||||
userAgent: meta.userAgent?.slice(0, 300) ?? null,
|
|
||||||
ip: meta.ip?.slice(0, 60) ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Gelegenheit nutzen und abgelaufene Sitzungen wegraeumen.
|
|
||||||
void prisma.session
|
|
||||||
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
|
|
||||||
.catch(() => undefined);
|
|
||||||
|
|
||||||
return { token, expiresAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSessionCookie(response: NextResponse, token: string, expiresAt: Date) {
|
|
||||||
response.cookies.set({
|
|
||||||
name: SESSION_COOKIE,
|
|
||||||
value: token,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: COOKIES_SECURE,
|
|
||||||
path: "/",
|
|
||||||
expires: expiresAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSessionCookie(response: NextResponse) {
|
|
||||||
response.cookies.set({
|
|
||||||
name: SESSION_COOKIE,
|
|
||||||
value: "",
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: COOKIES_SECURE,
|
|
||||||
path: "/",
|
|
||||||
maxAge: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liest die Sitzung aus dem Cookie und prueft sie gegen die Datenbank.
|
|
||||||
* Gibt null zurueck, wenn kein oder ein ungueltiges Token vorliegt oder das
|
|
||||||
* Konto deaktiviert wurde.
|
|
||||||
*/
|
|
||||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
|
||||||
const store = await cookies();
|
|
||||||
const token = store.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const session = await prisma.session.findUnique({
|
|
||||||
where: { tokenHash: hashToken(token) },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) return null;
|
|
||||||
|
|
||||||
if (session.expiresAt.getTime() <= Date.now()) {
|
|
||||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.user.isActive) return null;
|
|
||||||
|
|
||||||
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
|
|
||||||
// Datenbank anfasst.
|
|
||||||
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
|
|
||||||
void prisma.session
|
|
||||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toSessionUser(session.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function destroyCurrentSession(): Promise<void> {
|
|
||||||
const store = await cookies();
|
|
||||||
const token = store.get(SESSION_COOKIE)?.value;
|
|
||||||
if (!token) return;
|
|
||||||
await prisma.session
|
|
||||||
.deleteMany({ where: { tokenHash: hashToken(token) } })
|
|
||||||
.catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Meldet ein Konto ueberall ab – nach Passwortwechsel oder Sperrung. */
|
|
||||||
export async function destroyAllSessions(userId: string): Promise<void> {
|
|
||||||
await prisma.session.deleteMany({ where: { userId } });
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import type { AppSettings } from "@prisma/client";
|
|
||||||
import { APP_NAME } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
|
|
||||||
* sie wird beim ersten Zugriff angelegt.
|
|
||||||
*/
|
|
||||||
export async function getAppSettings(): Promise<AppSettings> {
|
|
||||||
return prisma.appSettings.upsert({
|
|
||||||
where: { id: "singleton" },
|
|
||||||
create: { id: "singleton", rpName: APP_NAME },
|
|
||||||
update: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InstanceState {
|
|
||||||
settings: AppSettings;
|
|
||||||
userCount: number;
|
|
||||||
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
|
|
||||||
needsSetup: boolean;
|
|
||||||
/** Darf sich jemand selbst registrieren? */
|
|
||||||
registrationOpen: boolean;
|
|
||||||
multiUser: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInstanceState(): Promise<InstanceState> {
|
|
||||||
const [settings, userCount] = await Promise.all([
|
|
||||||
getAppSettings(),
|
|
||||||
prisma.user.count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const multiUser = settings.mode === "MULTI_USER";
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings,
|
|
||||||
userCount,
|
|
||||||
needsSetup: userCount === 0,
|
|
||||||
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
|
|
||||||
registrationOpen: multiUser && settings.allowRegistration,
|
|
||||||
multiUser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
/** Benutzerfelder, die an den Browser gehen duerfen. */
|
|
||||||
export const userSafeSelect = {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
displayName: true,
|
|
||||||
email: true,
|
|
||||||
role: true,
|
|
||||||
isActive: true,
|
|
||||||
lastLoginAt: true,
|
|
||||||
lockedUntil: true,
|
|
||||||
createdAt: true,
|
|
||||||
_count: { select: { credentials: true, projects: true, sessions: true } },
|
|
||||||
} satisfies Prisma.UserSelect;
|
|
||||||
|
|
||||||
export type UserListEntry = Prisma.UserGetPayload<{ select: typeof userSafeSelect }>;
|
|
||||||
|
|
||||||
/** Sperre nach zu vielen Fehlversuchen. */
|
|
||||||
const LOCK_THRESHOLD = 8;
|
|
||||||
const LOCK_MINUTES = 15;
|
|
||||||
|
|
||||||
export async function noteFailedLogin(userId: string): Promise<void> {
|
|
||||||
const user = await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { failedLoginCount: { increment: 1 } },
|
|
||||||
select: { failedLoginCount: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user.failedLoginCount >= LOCK_THRESHOLD) {
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000),
|
|
||||||
failedLoginCount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function noteSuccessfulLogin(userId: string): Promise<void> {
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { lastLoginAt: new Date(), failedLoginCount: 0, lockedUntil: null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function lockRemainingSeconds(lockedUntil: Date | null): number {
|
|
||||||
if (!lockedUntil) return 0;
|
|
||||||
const remaining = lockedUntil.getTime() - Date.now();
|
|
||||||
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uebernimmt Projekte und Verbindungen ohne Besitzer.
|
|
||||||
*
|
|
||||||
* Greift genau einmal: beim Anlegen des ersten Kontos. So gehen Daten, die
|
|
||||||
* vor der Einfuehrung der Benutzerverwaltung entstanden sind, nicht verloren.
|
|
||||||
*/
|
|
||||||
export async function claimOrphanedData(userId: string): Promise<{
|
|
||||||
projects: number;
|
|
||||||
connections: number;
|
|
||||||
}> {
|
|
||||||
const [projects, connections] = await prisma.$transaction([
|
|
||||||
prisma.project.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
|
||||||
prisma.gitConnection.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
|
|
||||||
]);
|
|
||||||
return { projects: projects.count, connections: connections.count };
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import {
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
} from "@simplewebauthn/server";
|
|
||||||
import type {
|
|
||||||
AuthenticationResponseJSON,
|
|
||||||
AuthenticatorTransportFuture,
|
|
||||||
PublicKeyCredentialCreationOptionsJSON,
|
|
||||||
PublicKeyCredentialRequestOptionsJSON,
|
|
||||||
RegistrationResponseJSON,
|
|
||||||
} from "@simplewebauthn/server";
|
|
||||||
import type { Credential, User } from "@prisma/client";
|
|
||||||
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
|
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Passkeys (WebAuthn).
|
|
||||||
*
|
|
||||||
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
|
|
||||||
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
|
|
||||||
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
|
|
||||||
*
|
|
||||||
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
|
|
||||||
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
|
|
||||||
* neu angelegt werden.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
export class WebAuthnUnavailableError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super(
|
|
||||||
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
|
|
||||||
"https-Adresse (oder http://localhost) setzen.",
|
|
||||||
);
|
|
||||||
this.name = "WebAuthnUnavailableError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertAvailable() {
|
|
||||||
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function storeChallenge(
|
|
||||||
challenge: string,
|
|
||||||
purpose: "registration" | "authentication",
|
|
||||||
userId: string | null,
|
|
||||||
) {
|
|
||||||
// Abgelaufene Challenges bei der Gelegenheit entfernen.
|
|
||||||
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
|
|
||||||
await prisma.webAuthnChallenge.create({
|
|
||||||
data: {
|
|
||||||
challenge,
|
|
||||||
purpose,
|
|
||||||
userId,
|
|
||||||
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
|
|
||||||
async function consumeChallenge(
|
|
||||||
challenge: string,
|
|
||||||
purpose: "registration" | "authentication",
|
|
||||||
): Promise<{ userId: string | null } | null> {
|
|
||||||
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
|
|
||||||
if (!stored) return null;
|
|
||||||
|
|
||||||
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
|
|
||||||
|
|
||||||
if (stored.purpose !== purpose) return null;
|
|
||||||
if (stored.expiresAt.getTime() <= Date.now()) return null;
|
|
||||||
|
|
||||||
return { userId: stored.userId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registrierung eines neuen Passkeys ───────────────────────
|
|
||||||
|
|
||||||
export async function buildRegistrationOptions(
|
|
||||||
user: Pick<User, "id" | "username" | "displayName">,
|
|
||||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
|
||||||
assertAvailable();
|
|
||||||
|
|
||||||
const existing = await prisma.credential.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: { credentialId: true, transports: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
|
||||||
rpName: APP_NAME,
|
|
||||||
rpID: WEBAUTHN_RP_ID,
|
|
||||||
userID: new TextEncoder().encode(user.id),
|
|
||||||
userName: user.username,
|
|
||||||
userDisplayName: user.displayName ?? user.username,
|
|
||||||
attestationType: "none",
|
|
||||||
// Bereits registrierte Passkeys ausschliessen, damit derselbe
|
|
||||||
// Authenticator nicht doppelt hinterlegt wird.
|
|
||||||
excludeCredentials: existing.map((credential) => ({
|
|
||||||
id: credential.credentialId,
|
|
||||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
|
||||||
})),
|
|
||||||
authenticatorSelection: {
|
|
||||||
residentKey: "preferred",
|
|
||||||
userVerification: "preferred",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await storeChallenge(options.challenge, "registration", user.id);
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmRegistration(
|
|
||||||
userId: string,
|
|
||||||
response: RegistrationResponseJSON,
|
|
||||||
name: string | null,
|
|
||||||
): Promise<Credential> {
|
|
||||||
assertAvailable();
|
|
||||||
|
|
||||||
const expectedChallenge = response.response.clientDataJSON
|
|
||||||
? decodeChallenge(response.response.clientDataJSON)
|
|
||||||
: null;
|
|
||||||
if (!expectedChallenge) {
|
|
||||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = await consumeChallenge(expectedChallenge, "registration");
|
|
||||||
if (!stored) {
|
|
||||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
|
||||||
}
|
|
||||||
if (stored.userId !== userId) {
|
|
||||||
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const verification = await verifyRegistrationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
|
||||||
expectedRPID: WEBAUTHN_RP_ID,
|
|
||||||
requireUserVerification: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
|
||||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
||||||
|
|
||||||
return prisma.credential.create({
|
|
||||||
data: {
|
|
||||||
userId,
|
|
||||||
credentialId: credential.id,
|
|
||||||
publicKey: Buffer.from(credential.publicKey),
|
|
||||||
counter: credential.counter,
|
|
||||||
transports: (credential.transports ?? []) as string[],
|
|
||||||
deviceType: credentialDeviceType,
|
|
||||||
backedUp: credentialBackedUp,
|
|
||||||
name: name?.trim() || null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Anmeldung per Passkey ────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
|
|
||||||
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
|
|
||||||
*/
|
|
||||||
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
|
||||||
assertAvailable();
|
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
|
||||||
rpID: WEBAUTHN_RP_ID,
|
|
||||||
userVerification: "preferred",
|
|
||||||
});
|
|
||||||
|
|
||||||
await storeChallenge(options.challenge, "authentication", null);
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasskeyLoginResult {
|
|
||||||
userId: string;
|
|
||||||
credentialId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmAuthentication(
|
|
||||||
response: AuthenticationResponseJSON,
|
|
||||||
): Promise<PasskeyLoginResult> {
|
|
||||||
assertAvailable();
|
|
||||||
|
|
||||||
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
|
|
||||||
if (!expectedChallenge) {
|
|
||||||
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const stored = await consumeChallenge(expectedChallenge, "authentication");
|
|
||||||
if (!stored) {
|
|
||||||
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const credential = await prisma.credential.findUnique({
|
|
||||||
where: { credentialId: response.id },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (!credential) {
|
|
||||||
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
|
|
||||||
}
|
|
||||||
if (!credential.user.isActive) {
|
|
||||||
throw new Error("Das Konto ist deaktiviert.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const verification = await verifyAuthenticationResponse({
|
|
||||||
response,
|
|
||||||
expectedChallenge,
|
|
||||||
expectedOrigin: WEBAUTHN_ORIGIN,
|
|
||||||
expectedRPID: WEBAUTHN_RP_ID,
|
|
||||||
requireUserVerification: false,
|
|
||||||
credential: {
|
|
||||||
id: credential.credentialId,
|
|
||||||
publicKey: new Uint8Array(credential.publicKey),
|
|
||||||
counter: credential.counter,
|
|
||||||
transports: credential.transports as AuthenticatorTransportFuture[],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!verification.verified) {
|
|
||||||
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
|
|
||||||
// SimpleWebAuthn wirft in dem Fall bereits oben.
|
|
||||||
await prisma.credential.update({
|
|
||||||
where: { id: credential.id },
|
|
||||||
data: {
|
|
||||||
counter: verification.authenticationInfo.newCounter,
|
|
||||||
lastUsedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { userId: credential.userId, credentialId: credential.credentialId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
|
|
||||||
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
|
|
||||||
* zu finden und zu entwerten.
|
|
||||||
*/
|
|
||||||
function decodeChallenge(clientDataJSON: string): string | null {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
|
|
||||||
challenge?: string;
|
|
||||||
};
|
|
||||||
return typeof json.challenge === "string" ? json.challenge : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Laufzeitkonfiguration.
|
|
||||||
*
|
|
||||||
* Bewusst ohne NEXT_PUBLIC_-Variablen: die wuerden beim Build fest in das
|
|
||||||
* JavaScript-Bundle geschrieben. So laesst sich dasselbe Docker-Image ohne
|
|
||||||
* Neubau anders benennen und unter einer anderen URL betreiben. Werte, die
|
|
||||||
* der Browser braucht, reicht das Layout als Props weiter.
|
|
||||||
*/
|
|
||||||
|
|
||||||
function trimmed(value: string | undefined): string | null {
|
|
||||||
const result = value?.trim();
|
|
||||||
return result ? result : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
|
|
||||||
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
|
|
||||||
|
|
||||||
/** Kleine Zeile unter dem Namen in der Kopfzeile. */
|
|
||||||
export const APP_TAGLINE = trimmed(process.env.APP_TAGLINE) ?? "Vibe Coding";
|
|
||||||
|
|
||||||
const RAW_APP_URL = trimmed(process.env.APP_URL) ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
function parseAppUrl(raw: string): URL {
|
|
||||||
try {
|
|
||||||
const url = new URL(raw);
|
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
||||||
throw new Error(`APP_URL muss http oder https sein, ist aber "${url.protocol}"`);
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`APP_URL ist keine gueltige URL: "${raw}". Beispiel: https://nebula.example.com` +
|
|
||||||
` (${error instanceof Error ? error.message : String(error)})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const APP_URL_PARSED = parseAppUrl(RAW_APP_URL);
|
|
||||||
|
|
||||||
/** Vollstaendige Basis-URL ohne abschliessenden Slash. */
|
|
||||||
export const APP_URL = APP_URL_PARSED.origin;
|
|
||||||
|
|
||||||
/** Origin, den WebAuthn in der Assertion erwartet. */
|
|
||||||
export const WEBAUTHN_ORIGIN = APP_URL_PARSED.origin;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relying-Party-ID fuer WebAuthn: der reine Hostname ohne Port.
|
|
||||||
* Passkeys sind an diesen Wert gebunden – aendert er sich, sind bestehende
|
|
||||||
* Passkeys nicht mehr verwendbar.
|
|
||||||
*/
|
|
||||||
export const WEBAUTHN_RP_ID = APP_URL_PARSED.hostname;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebAuthn verlangt einen sicheren Kontext. Ausnahme: localhost gilt in
|
|
||||||
* Browsern als sicher, auch ohne TLS.
|
|
||||||
*/
|
|
||||||
export const WEBAUTHN_AVAILABLE =
|
|
||||||
APP_URL_PARSED.protocol === "https:" ||
|
|
||||||
APP_URL_PARSED.hostname === "localhost" ||
|
|
||||||
APP_URL_PARSED.hostname === "127.0.0.1";
|
|
||||||
|
|
||||||
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
|
|
||||||
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
|
|
||||||
|
|
||||||
/** Lebensdauer einer Sitzung. */
|
|
||||||
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
|
|
||||||
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
export const SESSION_COOKIE = "nebula_session";
|
|
||||||
|
|
||||||
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
|
||||||
export function describeConfig(): string {
|
|
||||||
return [
|
|
||||||
`name=${APP_NAME}`,
|
|
||||||
`url=${APP_URL}`,
|
|
||||||
`rpId=${WEBAUTHN_RP_ID}`,
|
|
||||||
`passkeys=${WEBAUTHN_AVAILABLE ? "moeglich" : "deaktiviert (kein sicherer Kontext)"}`,
|
|
||||||
`secureCookies=${COOKIES_SECURE}`,
|
|
||||||
].join(" ");
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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)}`;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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 } },
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user