35 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

Vier neue Ende-zu-Ende-Tests: Reihenfolge und Mehrfachauswahl ueber die API,
Mehrfachauswahl in der Oberflaeche, der Aenderungsverlauf und das Manifest
samt Symbolen ohne Anmeldung. Damit sind es 21.

Der Test zum Ziehen von Aufgaben war gelegentlich rot, ohne dass etwas kaputt
war: das Neuladen brach den noch laufenden Speicheraufruf ab. Er wartet jetzt
auf die Antwort, statt auf eine feste Pause zu hoffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:44 +02:00
florian.wildeandClaude Opus 5 16e8d31277 feat(ui): Aenderungsverlauf hinter der Versionsnummer
Ein Klick auf die Version in der Fusszeile zeigt, was in den bisherigen
Fassungen dazugekommen ist, was besser wurde und welche Fehler behoben sind.
Kein eigener Menuepunkt: gesucht wird so etwas dort, wo die Version steht.

Die Liste steht in lib/changelog.ts und nicht in einer CHANGELOG.md. Sie
wird in der Anwendung angezeigt, und zwei Quellen liefen prompt auseinander
- wie es der Versionsnummer schon einmal passiert ist, die eine Zeit lang in
der Fusszeile und in der package.json verschiedene Werte zeigte. Ein Test
haelt den obersten Eintrag und die package.json seitdem zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 d97b6f650a feat(pwa): als App installierbar
Web-Manifest mit eigenen Symbolen; auf dem Handy laesst sich Nebula zum
Startbildschirm hinzufuegen und laeuft dann ohne Adressleiste.

Das Manifest entsteht wie die uebrigen Metadaten aus einer Funktion, nicht
aus einer Konstanten: nur so wird APP_NAME zur Laufzeit gelesen und das
fertige Docker-Image bleibt umbenennbar.

Manifest und Symbole muessen an der Zugangsschranke vorbei - der Browser
holt sie, bevor sich jemand angemeldet hat. Sonst landeten sie auf der
Anmeldeseite und die Anwendung waere nicht installierbar.

Die maskierbare Fassung hat mehr Rand: Android schneidet Symbole je nach
Geraet rund, eckig oder als Tropfen zu. Die PNGs sind aus dem vorhandenen
SVG mit Chrome erzeugt - das spart eine Bildbibliothek als Abhaengigkeit.

Beim Ergaenzen des Apple-Symbols ist mir aufgefallen, dass ein eigener
icons-Eintrag den Verweis ersetzt, den Next aus app/icon.svg von selbst
erzeugt: die Seite hatte danach gar kein Symbol mehr und der Browser fiel
auf /favicon.ico zurueck, das es hier nicht gibt. Beide Eintraege stehen
jetzt ausdruecklich da, ein Test prueft den Verweis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:43 +02:00
florian.wildeandClaude Opus 5 49e253f70b feat(board): Mehrfachauswahl fuer Status, Tags und Loeschen
Kaestchen auf jeder Karte, darunter eine Leiste am unteren Rand. Fest
verankert, weil sie bei einer langen Liste oben laengst aus dem Blick
gescrollt waere, waehrend man noch auswaehlt.

Ein eigener Endpunkt statt vieler einzelner PATCHes: eine Transaktion statt
zwanzig Rundgaengen, und der Verlauf bekommt je Projekt genau einen Eintrag
statt eines Schwalls.

Was der Filter ausblendet, faellt aus der Auswahl - sonst traefe eine
Massenaenderung Projekte, die man gar nicht mehr vor Augen hat. Fremde IDs
sammelt der Server wegen der Besitzerbedingung erst gar nicht ein und meldet
sie als uebergangen.

Das Kaestchen ist gedaempft, aber immer sichtbar. Erst beim Ueberfahren
einzublenden waere auf einem Touchgeraet nicht erreichbar - und wer es nie
sieht, weiss nicht, dass es die Auswahl gibt.

Ansicht, Sortierung und Archivschalter ueberdauern nun das Neuladen. Wer sein
Brett von Hand anordnet, will es beim naechsten Aufruf auch sehen. Der
Suchbegriff bleibt aussen vor: eine Suche von gestern wiederherzustellen
waere eher verwirrend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 1400bca047 feat(board): freie Reihenfolge im Projektbrett
Karten lassen sich innerhalb einer Spalte anordnen, die Position bleibt am
Projekt. Das Aufgabenbrett kann das laengst - dass ausgerechnet das
Projektbrett der gewaehlten Sortierung folgte, war ein Bruch.

Die Sortierung im Kopf ist in dieser Ansicht deshalb abgeschaltet. Sie
stehen zu lassen, ohne dass sie wirkt, waere irrefuehrend.

Umsortieren fasst das Aenderungsdatum nicht an. Sonst schoebe ein Aufraeumen
im Brett saemtliche Projekte in "Zuletzt geaendert" nach oben - das Brett
wuerde die Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
Ein Statuswechsel dagegen ist eine echte Aenderung und darf das Datum
setzen. Nur er kommt auch in den Verlauf; ein blosser Rangwechsel nicht,
sonst bestuende der Verlauf bald aus nichts anderem.

Dabei fiel auf, dass neu angelegte, importierte und per Status verschobene
Projekte gar keine Position bekamen und sich alle auf 0 stapelten. Der Rang
wird jetzt an allen vier Stellen gesetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
florian.wildeandClaude Opus 5 5253b0a317 test: mehrere Repositories abdecken, Doku, Version 1.0.0
CI / pruefen (push) Has been cancelled
Drei neue Ende-zu-Ende-Tests: das Verwalten mehrerer Repositories samt
Datentrennung, das Lesen von Exportdateien der Version 1, und die
Uebernahme ohne verknuepftes Repository.

Dabei fiel auf, dass der Uebernahme-Endpunkt ohne Repository mit 404
antwortete, obwohl gar keine Repository-ID angefragt war. Wer nichts angibt,
hat schlicht noch keines verknuepft - das ist kein "nicht gefunden", sondern
409 mit dem Hinweis, eines zu hinterlegen.

Version 1.0.0: der Funktionsumfang ist beisammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 b882c870c3 feat(ui): Repositories im Git-Panel verwalten
Eine Leiste mit allen Repositories des Projekts, sobald es mehr als eines
gibt - bei einem einzigen waere eine Leiste mit genau einem Knopf nur
Ballast. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich.

Verknuepfen und Loesen laufen ueber einen eigenen Dialog mit derselben
Repo-Auswahl wie beim Projekt. Der Projektdialog bearbeitet weiterhin nur
das fuehrende: eine Liste darin wuerde den Dialog sprengen, in dem es
eigentlich um das Projekt geht.

Eine frei waehlbare Bezeichnung tritt an die Stelle von owner/name. Die
Karte zeigt "+2 Repos" und im Titel, welche es sind.

repoTitle steht in lib/git/meta statt bei den uebrigen Repository-Helfern:
lib/repositories zieht Prisma und damit node:-Module nach, die in einer
Client-Komponente nicht landen duerfen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:08 +02:00
florian.wildeandClaude Opus 5 a843f08211 feat(git): mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber
eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene
Tabelle, der Snapshot haengt jetzt am Repository.

Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden
kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem*
erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt.

Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt
verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand
bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten
Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem
seiner Teile gearbeitet wird.

Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst
zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie
sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander -
ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das,
worauf Rate-Limits reagieren.

Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und
Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft
gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe
Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere
Aufgaben ohne Herkunft bleiben erlaubt.

Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das
fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo,
und der Import muss von der neuen Tabelle nichts wissen.

Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt
lesbar: dort stand genau ein Repository flach am Projekt.

Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den
Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht.
Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung,
Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo
bekommen keine leere Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:23:53 +02:00
florian.wildeandClaude Opus 5 2bff6faf3d feat(auth): Zwei-Faktor-Anmeldung per TOTP
Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und
zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht.

Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer
Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und
der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht
nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen
waere unverhaeltnismaessig.

Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein
bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte
und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus.

Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen
Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine
Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung
durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf
Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe
durchprobiert werden.

Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind
zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein
langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und
kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von
Hand abgeschrieben werden.

Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter
Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen
entfernen koennen.

Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit
Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten
mit falschem und richtigem Passwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:37 +02:00
florian.wildeandClaude Opus 5 af562584ad feat(projects): Aktivitaetsverlauf je Projekt
Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite
beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits.

Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status
und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder
zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe
alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor.

Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel
stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als
beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits
hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den
Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf.

Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag
soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst
nicht mehr gibt.

Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang
stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was
in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo
nicht gibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:36:19 +02:00
florian.wildeandClaude Opus 5 fa5fd78318 test: Schnellsuche und Issue-Uebernahme abdecken, Version 0.9.0
CI / pruefen (push) Has been cancelled
Zwei neue Ende-zu-Ende-Tests: die Palette findet Projekt und Notiz und
oeffnet den Treffer mit Enter; die Uebernahme meldet ohne Repo-Stand einen
Hinweis statt still zu scheitern. Damit sind es 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 75460c8cf4 feat(tasks): offene Git-Issues als Aufgaben uebernehmen
Ein Klick im Git-Panel macht aus den offenen Issues Aufgaben - mit Titel,
Labels und einem Link zurueck ins Git-System.

Grundlage ist der zuletzt geholte Snapshot. Hier wird bewusst nichts
nachgeladen: ein Klick soll nicht ungefragt fremde APIs ansprechen und
womoeglich in ein Rate-Limit laufen. Wer den neuesten Stand will, gleicht
vorher ab; ohne Snapshot sagt der Server das statt still nichts zu tun.

Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein
zweiter Klick legt deshalb keine Dubletten an, auch wenn die Aufgabe
inzwischen verschoben oder umbenannt wurde. NULL gilt in Postgres als
verschieden, von Hand angelegte Aufgaben stoeren die Eindeutigkeit also
nicht.

Nachgewiesen an vercel/swr: 15 offene Issues uebernommen, beim zweiten
Aufruf 0 angelegt und 15 uebersprungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 3bad4ace10 feat(ui): Schnellsuche mit Strg+K
Palette ueber die ganze Anwendung: Projekte nach Name, Kurzbeschreibung und
Tags, Notizen ueber die serverseitige Volltextsuche, dazu die Bereiche.
Ohne Eingabe stehen die zuletzt bearbeiteten Projekte bereit - danach sucht
man ueblicherweise.

Bedienung ueber Tastatur, wie man es erwartet: Pfeile waehlen, Enter oeffnet,
Escape schliesst, der ausgewaehlte Eintrag bleibt im Blick.

Die Projektliste wird beim ersten Oeffnen geholt und dann behalten. Sie bei
jedem Tastendruck neu zu laden waere Verschwendung, und veraltet ist sie
hoechstens um die Dauer einer Sitzung. Notizen sucht dagegen der Server,
entprellt ab zwei Zeichen - sie mitzuladen wuerde die Seite aufblaehen.

Wie Menue und Dialog haengt die Palette per Portal am body, aus denselben
Gruenden: overflow-hidden und backdrop-filter der Karten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:36:44 +02:00
florian.wildeandClaude Opus 5 a237a79915 docs: Stillstandsmeldung dokumentieren, Version 0.8.0
CI / pruefen (push) Has been cancelled
FEATURES erklaert, warum der Massstab der letzte Commit ist und nicht das
Aenderungsdatum, wen die Pruefung auslaesst und wann erneut gemeldet wird.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 18ed4de9f9 feat(admin): Schwelle fuer den Stillstand einstellbar
Eigener Abschnitt mit der Schwelle von 14 Tagen bis zu einem Jahr, dem
Zeitpunkt der letzten Pruefung und der Zahl der Empfaenger. Steht die auf
null, sagt der Abschnitt das und verweist auf "Mein Konto".

Eine Aenderung der Schwelle setzt lastStaleCheckAt zurueck, damit sie beim
naechsten Tick greift und nicht erst in 24 Stunden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 4d1389c3a8 feat(notify): stillstehende Projekte melden
Vierter Anlass: seit laengerem kein Commit. Er haengt an keinem Ereignis -
es passiert ja gerade nichts - und braucht deshalb einen eigenen Durchlauf
im Zeitgeber, hoechstens einmal am Tag und unabhaengig vom Abgleich. Hoert
niemand auf den Anlass, laeuft die Pruefung gar nicht erst.

Massstab ist ausschliesslich der letzte Commit im verknuepften Repository.
Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und
wuerde den Stillstand immer wieder zuruecksetzen. Projekte ohne Repository
bleiben deshalb aussen vor, ebenso fertige und archivierte.

Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit
kam und es seitdem wieder still wurde - ein Projekt, das aufwacht und wieder
einschlaeft, meldet sich also ein zweites Mal.

Beim Testen kam heraus, dass ich den neuen Anlass zwar ins Prisma-Schema und
in die Oberflaeche eingetragen hatte, nicht aber ins Zod-Schema - die API
lehnte ihn mit 422 ab. Behoben, und ein Test haelt beide Listen kuenftig
zusammen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:55:15 +02:00
florian.wildeandClaude Opus 5 252c0686df docs: Benachrichtigungen dokumentieren, Version 0.7.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt die drei Anlaesse, die Entprellung samt ihrer beiden
Feinheiten, die drei Zustellwege und die Absicherung. README bekommt die
neuen Endpunkte und Umgebungsvariablen, .env.example die SMTP-Vorlage.

ROADMAP: Benachrichtigungen erledigt. Neu an erster Stelle steht das Melden
stillstehender Projekte - das ist der einzige Anlass, der kein Ereignis hat,
an dem er haengt, und braucht deshalb einen eigenen Durchlauf. Ein doppelter
Eintrag zu Webhooks statt Polling ist dabei aufgefallen und entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:23:06 +02:00
florian.wildeandClaude Opus 5 f1e4271488 feat(ui): Benachrichtigungsziele unter "Mein Konto"
Beliebig viele Ziele je Konto, jedes mit eigenen Anlaessen. Jedes laesst sich
einzeln testen und pausieren; Ergebnis und Zeitpunkt der letzten Zustellung
stehen an der Karte.

Der Weg laesst sich nach dem Anlegen nicht mehr aendern - ein Webhook-Ziel zu
einer E-Mail-Adresse umzubiegen ergaebe keinen Sinn und muesste das
Geheimnis mitwandern lassen. Ist kein SMTP hinterlegt, ist die Auswahl
"E-Mail" gesperrt und erklaert, warum.

targetSafeSelect liegt in lib/notify/types statt in der Route: Next laesst in
route.ts nur bestimmte Exporte zu und bricht den Bau bei allem anderen ab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 3bfed6d9f6 feat(notify): Benachrichtigungen ueber ntfy, Webhook und E-Mail
Drei Anlaesse: fehlgeschlagener Abgleich, rote CI, neues Release. Die Daten
dafuer liegen seit dem CI-Status vor, es fehlte nur der Weg nach draussen.

Der Kern ist die Entprellung. Gemeldet wird ausschliesslich bei einer
Aenderung gegenueber dem zuletzt gemeldeten Stand, der am Projekt vermerkt
ist. Ein Repo mit abgelaufenem Token meldet sich damit einmal statt bei
jedem stuendlichen Durchlauf. Zwei Feinheiten:

- Beim allerersten Abgleich wird kein Release gemeldet, sonst waere jede
  bereits vorhandene Version eine "Neuigkeit".
- Der Merkstand wird auch ohne eingerichtetes Ziel fortgeschrieben, sonst
  kaeme beim ersten Ziel die ganze Vergangenheit hinterher.

Zustellwege:
- ntfy mit Titel, Prioritaet und Symbol als Kopfzeilen, optionalem
  Bearer-Token. Emojis werden aus den Kopfzeilen entfernt statt durch
  Fragezeichen ersetzt - Kopfzeilen duerfen nur Latin-1 tragen, und das
  Sinnbild liefert ohnehin der Tags-Header.
- Webhook mit JSON, Projektbezug und Tieflink. Mit Geheimnis wird der Rumpf
  per HMAC-SHA256 signiert, damit der Empfaenger die Herkunft pruefen kann.
- E-Mail ueber SMTP, verfuegbar sobald SMTP_HOST gesetzt ist.

Zieladressen sind Benutzereingaben, die der Server abruft - sie laufen
deshalb durch denselben SSRF-Schutz wie die Git-Verbindungen. Redirects
werden nicht verfolgt, weil ein Umleitungsziel nicht geprueft waere.
Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung darf
den Abgleich nicht scheitern lassen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 10:22:50 +02:00
florian.wildeandClaude Opus 5 d1b9dfe535 docs: Testebenen dokumentieren, Version 0.6.0
CI / pruefen (push) Has been cancelled
FEATURES beschreibt beide Ebenen - Unit-Tests ohne Datenbank und
Ende-zu-Ende-Tests gegen die gebaute Anwendung - samt der beiden
Vorkehrungen gegen einen Lauf auf der echten Datenbank. Dazu die
Voraussetzungen fuer Gitea Actions.

ROADMAP: der Testausbau war Punkt 1 und ist erledigt; Benachrichtigungen
ruecken auf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 ebea3786b1 ci: Pipeline fuer Gitea Actions
Prueft jeden Push und jeden Pull Request: Typen, Unit-Tests, Bau und
Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlaegt etwas
fehl, wird der Playwright-Bericht als Artefakt abgelegt.

Der Datenbankname des Dienstes enthaelt "test", sonst verweigert
global-setup.ts den Dienst. In CI wird das mitgelieferte Chromium benutzt
statt des oertlich installierten Chrome.

Die Syntax ist GitHub-kompatibel - fuer GitHub genuegt es, die Datei nach
.github/workflows zu kopieren.

Nebenbei bekommt der Knopf des Benutzermenues eine Beschriftung. Ohne sie
las ein Screenreader auf schmalen Anzeigen nur das zweibuchstabige Kuerzel
vor, und im Test war er nicht eindeutig vom Datenmenue zu unterscheiden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:55 +02:00
florian.wildeandClaude Opus 5 6e63fec49b test: Ende-zu-Ende-Tests mit Playwright
13 Tests gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 -
also durch Middleware, Authentifizierung, Validierung und Datenbank hindurch.
Genau die Schicht, die Unit-Tests nicht erreichen. Laufzeit rund 20 Sekunden.

api.spec.ts prueft, was bisher nur von Hand geprueft war:
- Einrichtung sperrt sich nach dem ersten Konto
- ohne Anmeldung liefert jeder Endpunkt 401
- fremdes Origin und falscher Content-Type werden abgewiesen
- SSRF-Sperren greifen
- Datentrennung: ein zweites Konto bekommt auf fremde Projekte, Aufgaben und
  Adminwege durchgaengig 404 beziehungsweise 403, das Original bleibt
  unveraendert
- Aufgaben: Reihenfolge, abgeleiteter Fortschritt, untergeschobene IDs
- Export ohne Geheimnisse, Import je Strategie

ui.spec.ts geht den Weg eines Menschen: anmelden, Projekt anlegen, Aufgaben
anlegen und ziehen, suchen, abmelden. Das Ziehen wird nach einem Neuladen
erneut geprueft - sonst waere nur die Anzeige getestet, nicht das Speichern.

Zwei Vorkehrungen:
- global-setup.ts bricht ab, wenn der Datenbankname nicht "test" enthaelt,
  und zwar vor dem TRUNCATE. Der teuerste denkbare Fehler waere eine
  Testreihe, die die Produktivdaten leert.
- Playwright startet den Server selbst mit eigener DATABASE_URL und eigenem
  Port; eine laufende Instanz auf 3000 stoert nicht.

Beim Schreiben zeigte sich, dass synthetische Zieh-Gesten eine Pause vor dem
Loslassen brauchen - dnd-kit wertet die Bewegung in einem eigenen Frame aus
und faende sonst kein Ziel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 3ce866da2c docs: CLAUDE.md mit Projektkontext
Damit eine neue Sitzung in diesem Ordner nicht bei null anfaengt: die
Befehle, die nicht verhandelbaren Regeln (Datentrennung, Pruefung in jedem
Endpunkt, keine Geheimnisse nach aussen) und vor allem die Fallstricke, die
in diesem Projekt schon zugeschlagen haben - node-Module in
Client-Komponenten, instrumentation.ts im Edge-Bundle, CSS-Klassen ausserhalb
von @layer, Overlays in Karten mit backdrop-filter.

Dazu der Hinweis, dass Migrationen hier ueber "migrate diff" entstehen, weil
"migrate dev" interaktiv ist, und die Erinnerung, bei Oberflaechenaenderungen
tatsaechlich hinzusehen statt es zu behaupten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:50:36 +02:00
florian.wildeandClaude Opus 5 5b40d5e4ff feat(api): progressFromTasks im Projekt-Endpunkt
Der Schalter laesst sich ueber PATCH /api/projects/:id setzen. Beim
Einschalten wird der abgeleitete Wert sofort berechnet, damit er nicht bis
zur naechsten Aufgabenaenderung falsch steht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:30:04 +02:00
florian.wildeandClaude Opus 5 c50fa811c5 docs: Aufgabenbrett dokumentieren
FEATURES bekommt ein eigenes Kapitel: die vier Spalten, das Ziehen mit
Sortierung, Faelligkeiten, der abgeleitete Fortschritt und die Erklaerung,
warum die Reihenfolge als vollstaendige Liste je Spalte gespeichert wird.

ROADMAP: "Aufgaben und Checklisten" wandert zu den erledigten Punkten. Neu
unter den Kleinigkeiten stehen wiederkehrende Aufgaben und das Uebernehmen
offener Git-Issues als Aufgaben - beides ergibt sich erst jetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:51 +02:00
florian.wildeandClaude Opus 5 e40a3bd62e fix: Warnung beim Einlesen der Versionsnummer beseitigen
Der benannte Import aus der package.json loeste in Webpack die Warnung
"Should not import the named export 'version'" aus. Jetzt als Standardimport.
Version auf 0.5.0 gehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 79c4bd1967 feat(export): Aufgaben in Export, Import und Sicherung aufnehmen
Aufgaben gehen mit - JSON-Export, Import und Instanz-Sicherung. Beim Import
wird die Position aus der Datei uebernommen, notfalls die Reihenfolge in ihr.
Wie bei den Notizen werden Aufgaben beim Ersetzen ausgetauscht statt
zusammengefuehrt, damit ein erneuter Import keine Dubletten erzeugt.

Der Markdown-Export bekommt einen Abschnitt "Aufgaben", nach Spalten
gegliedert und als Checkliste geschrieben - so bleibt der Stand auch
ausserhalb von Nebula lesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:50 +02:00
florian.wildeandClaude Opus 5 0212dad2bf feat(ui): Aufgabenbrett auf der Projektseite
Vier Spalten, Karten per Ziehen verschiebbar - zwischen den Spalten und
innerhalb einer Spalte. Waehrend des Ziehens fuehrt der oertliche Zustand,
danach wird die neue Ordnung als Ganzes gespeichert; scheitert das, springt
das Brett auf den Serverstand zurueck.

Details:
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den
  Dialog oeffnet.
- Der Spaltenwechsel passiert schon beim Darueberziehen, nicht erst beim
  Ablegen - sonst wirkt das Brett traege.
- Der Serverstand wird nur uebernommen, wenn gerade nicht gezogen wird,
  sonst spraenge die Karte mitten in der Bewegung zurueck.
- Faelligkeiten faerben sich: ueberfaellig rot, heute gelb, demnaechst
  gedaempft.

Ist "Fortschritt aus Aufgaben" eingeschaltet, wird der Regler gesperrt statt
einen Wert anzubieten, der beim naechsten Aufgabenwechsel ueberschrieben
wuerde - im Dialog wie im Kopfbereich. Die Projektkarte zeigt das Verhaeltnis
als "3/8".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 72bcf824ce feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge
Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:29:28 +02:00
florian.wildeandClaude Opus 5 298da72b6d docs: v0.3.0 und v0.4.0 dokumentieren, Roadmap fortschreiben
FEATURES beschreibt Markdown, Volltextsuche, PR- und Issue-Listen, den
CI-Status samt Herkunft je Git-System, die Aktivitaetskurve inklusive ihrer
Grenze, das Kanban-Board und das Testfundament.

ROADMAP: sieben Punkte wandern zu den erledigten. Ganz oben steht jetzt der
Ausbau der Tests auf Datenbank und Browser - das Fundament steht, die
Schicht darueber fehlt und ich pruefe weiterhin von Hand. Danach
Benachrichtigungen, die von CI-Status und Abgleich nun profitieren wuerden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 92285b11fc fix: Versionsnummer aus einer einzigen Quelle
Die Fusszeile zeigte v0.2.0, die package.json stand auf 0.1.0 - beim
Auth-Feature hatte ich nur die Anzeige hochgezaehlt. Jetzt kommt die Version
aus der package.json, kann also nicht mehr auseinanderlaufen. Stand: 0.4.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 e55dab6473 feat(ui): Kanban-Board mit Ziehen und Ablegen
Dritte Ansicht neben Raster und Gruppierung: eine Spalte je Status, Karten
per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und erst danach
gespeichert; scheitert das Speichern, springt die Karte zurueck.

Details, die den Unterschied machen:
- Gezogen wird am Griff links, nicht an der ganzen Karte - so bleibt der
  Titel ein normaler Link.
- Das Ziehen startet erst nach sechs Pixeln Bewegung, sonst liesse sich die
  Karte nicht mehr anklicken. Auf Touchgeraeten nach kurzem Halten.
- Die mitgezogene Karte haengt in einem DragOverlay am Cursor, das Original
  bleibt blass stehen - sonst wuerde die Spalte unter dem Finger springen.
- Bestaetigt der Server die Verschiebung, wird die oertliche Ausnahme wieder
  entfernt, damit sie nicht veraltet.

Die Reihenfolge innerhalb einer Spalte folgt der gewaehlten Sortierung.
Frei sortieren hiesse, eine Position je Projekt zu speichern - bewusst
nicht gebaut.

Nachgewiesen im Browser: Karte aus "Idee" nach "Fertig" gezogen, Zielspalte
hebt sich hervor und zeigt "Hier ablegen", danach steht das Projekt
serverseitig auf DONE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:42 +02:00
florian.wildeandClaude Opus 5 de8f13f242 test: Fundament mit Vitest fuer die reine Logik
51 Tests, unter einer Sekunde, ohne DOM, Datenbank oder laufenden Server.
Abgedeckt sind die Stellen, an denen ein Fehler teuer waere:

- Slug-Erzeugung mit Umlauten, Tag-Aufbereitung, Textkuerzung
- URL-Zerlegung fuer https, SSH, verschachtelte GitLab-Gruppen und
  Oberflaechenpfade; Provider-Erkennung; Bildung der Aktivitaetskurve
- SSRF-Sperrlisten: Loopback, Link-Local samt Metadaten-Endpunkt, CGNAT,
  Multicast, IPv4-gemappte IPv6-Adressen; Formpruefung; alle drei
  Umgebungsschalter
- Passwoerter: scrypt-Rundlauf, unterschiedliches Salz je Hash, Ablageformat,
  Verhalten bei kaputten Hashes, Unicode-Normalisierung, Passwortregeln

Bewusst ohne Datenbank- und Browsertests - die brauchen Infrastruktur und
gehoeren in eine Pipeline. Steht als naechster Punkt in der ROADMAP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 03ce74faa2 feat(git): offene PRs und Issues, CI-Status und Commit-Aktivitaet
Bisher standen nur Zahlen da. Jetzt kommen die Inhalte mit.

Pull Requests und Issues: bis zu 50 offene je Liste, mit Nummer, Titel,
Autor, Alter, Labels und Link. Bei GitHub und Gitea liefert der
Issue-Endpunkt auch Pull Requests mit - die werden herausgefiltert.

CI-Status des letzten Laufs auf dem Default-Branch:
- GitHub ueber actions/runs; dort sind Lauf-Status und Ergebnis getrennt und
  werden auf einen Wert zusammengefuehrt
- GitLab ueber pipelines
- Gitea ueber den kombinierten Commit-Status. Das deckt Gitea Actions und
  externe Systeme wie Woodpecker gleichermassen ab - ein reiner
  Actions-Endpunkt existiert auf aelteren Instanzen nicht.

Commit-Aktivitaet als Balkenreihe. Berechnet aus den ohnehin geholten
Commits (jetzt 100 statt 15, angezeigt bleiben 15) statt ueber eigene
Statistik-Endpunkte: die gibt es nicht bei allen drei Systemen, und bei
GitHub antworten sie beim ersten Aufruf mit 202 und ohne Daten.

Beim Testen gegen gitlab-runner fiel auf, dass alle 100 Commits in die
letzten 30 Tage fielen - die frueheren Tage haetten faelschlich Null
gezeigt, obwohl wir ueber sie nichts wissen. Die Kurve wird deshalb auf den
tatsaechlich belegten Zeitraum gekuerzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:04:19 +02:00
florian.wildeandClaude Opus 5 4bb600bb06 feat(search): Volltextsuche ueber Notizen
Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
Name, Kurzbeschreibung, Tags und Repo-Pfad ab. Notizen liegen dort nicht vor
- sie mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb ein eigener
Endpunkt, der Postgres mit seiner deutschen Textsuche arbeiten laesst.

- websearch_to_tsquery versteht Eingaben wie "docker -compose" und wirft bei
  Unsinn keinen Fehler
- ts_headline liefert den Ausschnitt gleich mit; die Hervorhebung kommt als
  [[…]] zurueck, damit die Oberflaeche kein HTML aus der Datenbank einbetten
  muss
- deutsche Stammformen greifen: "Webhook" findet auch "Webhooks"
- entprellt ab zwei Zeichen, laufende Abfragen werden abgebrochen

Dazu ein GIN-Index auf dem Suchausdruck. Prisma kann Ausdrucksindizes im
Schema nicht abbilden - nachgeprueft: "migrate diff" laesst ihn in Ruhe und
schlaegt kein DROP vor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 01:03:55 +02:00
127 changed files with 12342 additions and 425 deletions
+15
View File
@@ -56,6 +56,21 @@ APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen. # Lebensdauer einer Anmeldung in Tagen.
SESSION_TTL_DAYS="30" SESSION_TTL_DAYS="30"
# ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet.
#
# E-Mail steht erst zur Verfuegung, wenn SMTP_HOST gesetzt ist:
# SMTP_HOST="smtp.example.com"
# SMTP_PORT="587"
# SMTP_SECURE="false" # true bei Port 465, sonst STARTTLS
# SMTP_USER="nebula@example.com"
# SMTP_PASSWORD="geheim"
# SMTP_FROM="Nebula <nebula@example.com>"
# Timeout fuer das Zustellen in Millisekunden
NOTIFY_TIMEOUT_MS="10000"
# ── Ausgehende Git-Zugriffe ────────────────────────────────── # ── Ausgehende Git-Zugriffe ──────────────────────────────────
# Timeout fuer Git-API-Aufrufe in Millisekunden # Timeout fuer Git-API-Aufrufe in Millisekunden
GIT_API_TIMEOUT_MS="15000" GIT_API_TIMEOUT_MS="15000"
+78
View File
@@ -0,0 +1,78 @@
# Prueft jeden Push und jeden Pull Request.
#
# Die Datei liegt unter .gitea/workflows und wird von Gitea Actions gelesen.
# Die Syntax ist mit GitHub Actions kompatibel – wer das Repo dorthin
# spiegelt, kopiert sie nach .github/workflows.
#
# Voraussetzung: In der Gitea-Instanz muessen Actions aktiviert sein
# (app.ini: [actions] ENABLED = true) und mindestens ein Runner registriert.
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
pruefen:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: nebula
POSTGRES_PASSWORD: nebula
# Der Name muss "test" enthalten – global-setup.ts leert die
# Datenbank und weigert sich bei allem anderen.
POSTGRES_DB: nebula_e2e_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U nebula"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
E2E_DATABASE_URL: postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public
# Nur fuer den Lauf – keine echten Daten im Spiel.
APP_SECRET: ci-lauf-schluessel-nur-fuer-die-pipeline-0123456789
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Abhaengigkeiten
run: npm ci
- name: Typen pruefen
run: npx tsc --noEmit
- name: Unit-Tests
run: npm test
- name: Browser fuer die Oberflaechentests
run: npx playwright install --with-deps chromium
# Baut zuerst und startet den Server dann selbst (siehe
# playwright.config.ts, webServer).
- name: Ende-zu-Ende-Tests
run: npm run test:e2e
- name: Bericht bei Fehlschlag sichern
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-bericht
path: |
playwright-report/
test-results/
retention-days: 7
+6
View File
@@ -46,6 +46,12 @@ yarn-error.log*
# Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz # Sicherungen aus scripts/backup.* – enthalten alle Daten der Instanz
/backups /backups
# Playwright
/test-results
/playwright-report
/blob-report
/playwright/.cache
# editors # editors
.idea/ .idea/
.vscode/* .vscode/*
+133
View File
@@ -0,0 +1,133 @@
# Nebula – Hinweise für Claude Code
Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
Betrieb: [README.md](README.md)
## Womit man arbeitet
```bash
docker compose up -d db # Postgres, muss laufen
npm run dev # Entwicklung
npm test # Unit-Tests (Vitest, ohne DB)
npx tsc --noEmit # Typprüfung
npm run build # Produktionsbau
docker compose --profile full up -d --build # alles im Container
```
**Vor dem `npm run build` einen laufenden `npm start`/`npm run dev` beenden.**
Sonst hält der Prozess die Prisma-Engine-DLL und der Bau bricht unter Windows
mit `EPERM … query_engine-windows.dll.node` ab.
## Nicht verhandelbar
**Datentrennung.** Jede Abfrage filtert auf den Besitzer, und zwar an erster
Stelle in der `where`-Klausel. Schreiben und Löschen laufen über
`updateMany`/`deleteMany` mit `ownerId`, damit fremde Datensätze gar nicht
erst getroffen werden. Fremde IDs liefern **404, nicht 403** — die Antwort
soll nicht bestätigen, dass es den Datensatz gibt.
**Jeder Endpunkt prüft selbst.** `requireUserApi()` bzw. `requireUserPage()`
sind die Sicherheitsgrenze. Die Middleware schaut nur, ob überhaupt ein
Cookie mitkommt — im Edge-Runtime gibt es keine Datenbank.
**Eingaben validieren.** Zod an jeder API-Grenze, Body über `readJsonBody`
(erzwingt `application/json` und begrenzt die Größe).
**Keine Geheimnisse nach außen.** Git-Tokens verlassen den Server nie;
Verbindungen werden über `connectionSafeSelect` geladen. Projekt-Exporte
enthalten grundsätzlich keine Tokens.
**Ausgehende Aufrufe nur über `lib/git/http.ts`** — dort greift der
SSRF-Schutz (`lib/security/ssrf.ts`).
## Fallstricke, die schon zugeschlagen haben
- **`node:`-Module in Client-Komponenten.** `lib/crypto`, `lib/auth/password`,
`lib/git/index` ziehen `node:crypto`/`node:net` nach. Client-Komponenten
importieren stattdessen `lib/git/meta` bzw. `lib/auth/constants`.
- **`instrumentation.ts` wird auch für Edge übersetzt.** Der dynamische Import
muss *innerhalb* von `if (process.env.NEXT_RUNTIME === "nodejs")` stehen,
sonst scheitert der Bau an `node:crypto`.
- **Eigene CSS-Klassen gehören in `app/components.css`** und dort in
`@layer components`. Außerhalb eines Layers gewinnen sie gegen
Tailwind-Utilities — dann greift `pl-9` neben `.field` nicht mehr.
- **Menüs und Dialoge über Portal.** Karten setzen `overflow-hidden` und
`backdrop-filter`; Letzteres erzeugt einen neuen Bezugsrahmen, in dem
`position: fixed` und `z-index` nicht mehr wie erwartet wirken. Dafür gibt
es `components/ui/Popover.tsx` und `components/ui/Modal.tsx`.
- **Overlays brauchen einen deckenden Hintergrund** (`.popover`), nicht
`.glass` — dessen 3,5 % Deckkraft lassen alles durchscheinen.
- **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst
weichen Server-Render und Hydration voneinander ab. Relative Zeiten
bekommen `suppressHydrationWarning`.
## Migrationen
`prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow"
npx prisma migrate diff --from-migrations prisma/migrations \
--to-schema-datamodel prisma/schema.prisma \
--shadow-database-url postgresql://nebula:nebula@localhost:5432/nebula_shadow \
--script --output prisma/migrations/<stempel>_<name>/migration.sql
npx prisma migrate deploy
```
**`--from-migrations`, nicht `--from-schema-datasource`.** Letzteres
vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
einmal.
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird
bislang in Ruhe gelassen, aber das ist nicht garantiert).
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
Vorgehen wie in `20260726230000_repositories`: neue Tabelle anlegen, Bestand
per `INSERT … SELECT` übernehmen, dann erst die alten Spalten entfernen.
Danach `migrate diff` erneut laufen lassen — kommt „empty migration“ heraus,
stimmt das Ergebnis mit dem Schema überein. Und gegen eine Kopie mit echten
Daten prüfen, nicht nur gegen eine leere Datenbank.
## Prüfen, nicht behaupten
Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`.
Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
sich nativ ab. Das sieht aus wie ein Anwendungsfehler, ist aber keiner:
```bash
cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/
```
`pkill` gibt es in dieser Git-Bash nicht. Prozesse über PowerShell beenden
(`Get-CimInstance Win32_Process … | Stop-Process`) — sonst läuft der alte
Server weiter, hält die Prisma-DLL und blockiert den nächsten Bau.
**Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen
und danach löschen:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_test"
DATABASE_URL=postgresql://nebula:nebula@localhost:5432/nebula_test npx prisma migrate deploy
```
## Sprache
Code-Kommentare, Commit-Nachrichten und Oberfläche auf Deutsch, **ohne
Umlaute in Bezeichnern und Commit-Nachrichten** (bestehende Konvention).
In Markdown-Dateien und in der Oberfläche sind Umlaute erwünscht.
Commits: Conventional Commits, Betreff deutsch, Rumpf erklärt **warum** —
nicht was der Diff ohnehin zeigt.
+288 -8
View File
@@ -27,6 +27,14 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die - **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig. Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar. Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
- **Zwei-Faktor per TOTP.** Zeitbasiertes Einmalkennwort zusätzlich zum Passwort, für jede
gängige Authenticator-App. Einrichtung über QR-Code oder abgetippten Schlüssel; scharf
wird er erst nach einem bestätigten Code, damit sich niemand aussperrt, der den Scan
nicht hinbekommt. Dazu zehn **Wiederherstellungscodes**, jeder genau einmal gültig —
sie werden nur bei der Ausgabe angezeigt. Abschalten und Neuerzeugen verlangen das
Passwort, nicht bloß eine offene Sitzung.
- Die Passkey-Anmeldung fragt **nicht** nach dem zweiten Faktor: der Passkey ist bereits an
ein Gerät gebunden und damit selbst der zweite Faktor.
- Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich - Ein Konto kann **ausschließlich** per Passkey nutzbar sein. Der letzte Passkey lässt sich
dann nicht entfernen, solange kein Passwort gesetzt ist. dann nicht entfernen, solange kein Passwort gesetzt ist.
- Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten. - Fehlversuche werden gezählt; nach acht sperrt sich das Konto für 15 Minuten.
@@ -35,9 +43,9 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
### 0.3 Mein Konto ### 0.3 Mein Konto
Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Passkeys verwalten · Anzeigename und E-Mail ändern · Passwort setzen oder wechseln · Zwei-Faktor einrichten und
aktive Sitzungen einsehen (Browser, Betriebssystem, IP, letzte Aktivität) · überall abschalten · Passkeys verwalten · aktive Sitzungen einsehen (Browser, Betriebssystem, IP,
abmelden, wobei das eigene Gerät angemeldet bleibt. letzte Aktivität) · überall abmelden, wobei das eigene Gerät angemeldet bleibt.
### 0.4 Benutzerverwaltung (Administratoren) ### 0.4 Benutzerverwaltung (Administratoren)
@@ -107,10 +115,57 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Zwei Ansichten**: freies Raster oder nach Status gruppierte Abschnitte. - **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks".
- **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar. - **Archiv** ist standardmäßig ausgeblendet und per Schalter einblendbar.
- **Leerzustände** mit direktem Einstieg statt einer leeren Fläche. - **Leerzustände** mit direktem Einstieg statt einer leeren Fläche.
### Schnellsuche
**`Strg+K`** (auf dem Mac `Cmd+K`) öffnet überall eine Palette:
- **Projekte** nach Name, Kurzbeschreibung und Tags — ohne Eingabe stehen die letzten
gleich bereit
- **Notizen** über dieselbe Volltextsuche wie im Board, entprellt ab zwei Zeichen
- **Bereiche**: Dashboard, Git-Verbindungen, Mein Konto, Admin-Einstellungen
Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die Projektliste
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung.
### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
- Gezogen wird am Griff links, nicht an der ganzen Karte — so bleibt der Titel ein
normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten.
- **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick ### Projektkarte auf einen Blick
Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge · Name und Favoritenstern · Kurzbeschreibung · Fortschrittsbalken · Provider-Badge ·
@@ -119,9 +174,59 @@ Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität
--- ---
## 2a. Aufgaben je Projekt
Jedes Projekt hat ein eigenes Aufgabenbrett mit vier Spalten: **Offen · In Arbeit ·
Blockiert · Erledigt**.
- **Ziehen und Ablegen** zwischen den Spalten **und innerhalb** einer Spalte. Anders als
beim Projektbrett ist die Reihenfolge hier bedeutsam, deshalb wird sie gespeichert.
- Gezogen wird am Griff links, damit ein Klick auf die Karte weiter den Dialog öffnet.
- Eine Aufgabe braucht nur einen Titel. Optional: Beschreibung (Markdown),
Fälligkeitsdatum und Labels.
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
gedämpft.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht.
### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
Labels und ein Link zurück ins Git-System.
- Grundlage ist der **zuletzt geholte Stand**, hier wird nichts nachgeladen. Ein Klick
soll nicht ungefragt fremde APIs ansprechen; wer den neuesten Stand will, gleicht
vorher ab.
- Die Issue-Nummer bleibt an der Aufgabe und ist je Projekt eindeutig. Ein zweiter Klick
legt deshalb **keine Dubletten** an, auch wenn die Aufgabe inzwischen verschoben oder
umbenannt wurde.
- Ohne Repo-Stand meldet der Server das mit einem Hinweis statt still nichts zu tun.
### Fortschritt aus Aufgaben
Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter
Aufgaben und wird bei jeder Änderung neu berechnet — der Regler ist dann gesperrt, statt
einen Wert anzubieten, der gleich wieder überschrieben würde.
Auf der Projektkarte im Dashboard steht das Verhältnis als `3/8`.
### Wie die Reihenfolge gespeichert wird
Beim Ablegen schickt die Oberfläche je Spalte die **vollständige Liste** der Aufgaben in
ihrer neuen Reihenfolge; der Server vergibt daraus Position und Status. Das ist
wiederholbar, kommt ohne Positionsrechnerei im Browser aus, und ein verlorener Aufruf
lässt sich einfach erneut senden. IDs, die nicht zum Projekt gehören, werden ignoriert
statt verschoben.
---
## 3. Notizen ## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text. - **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
- **Markdown wird dargestellt** — Überschriften, Listen, Tabellen, Codeblöcke, Zitate und
Links. Aus `- [ ] Aufgabe` werden echte Checklisten. Gilt auch für die Projekt-
beschreibung. Rohes HTML wird beim Rendern entfernt (`rehype-sanitize`) — der Inhalt
stammt vom Benutzer und wäre sonst ein XSS-Loch.
- **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben. - **Anpinnen** — angepinnte Notizen stehen oben und sind farblich hervorgehoben.
- **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach. - **Bearbeiten und Löschen** direkt in der Liste; Löschen fragt nach.
- **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung. - **Zeitstempel**: Anlagedatum immer, „bearbeitet vor …" nur bei echter Änderung.
@@ -134,6 +239,31 @@ Anzahl Notizen · Warnung bei Sync-Fehler · Tags · Statusauswahl · Priorität
## 4. Git-Anbindung ## 4. Git-Anbindung
### 4.0 Mehrere Repositories je Projekt
Ein Vorhaben besteht oft aus mehreren Repositories — Frontend und Backend etwa, oder
App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
- Im Git-Panel steht eine Leiste mit allen Repositories des Projekts, sobald es mehr als
eines gibt. Jedes hat eigene Kennzahlen, Listen, CI und eigenen Abgleich; „Alle" gleicht
sie nacheinander ab — nicht gleichzeitig, denn ein Schwung Anfragen an denselben Anbieter
ist genau das, worauf Rate-Limits reagieren.
- Eine frei wählbare **Bezeichnung** („Frontend") tritt an die Stelle von `owner/name`.
- Das erste Repository ist das **führende**. Wo nur eines dargestellt werden kann — auf der
Karte im Dashboard, im Kanban —, ist es dieses. Die Karte zeigt daneben `+2 Repos`.
Ein Abgleichsfehler an *irgendeinem* Repository erscheint auf der Karte, sonst bliebe er
unbemerkt.
- **Stillstand** bemisst sich am jüngsten Commit über alle Repositories: an einem Vorhaben
wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.
- Benachrichtigungen merken sich den gemeldeten Zustand **je Repository**. Sonst verdeckte
ein rotes Frontend die rote Pipeline des Backends. Der Betreff nennt das Repository nur,
wenn es nicht ohnehin so heißt wie das Projekt.
- Übernommene Issues merken sich, aus welchem Repository sie stammen — Frontend und Backend
haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.1 Unterstützte Systeme ### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit | | System | API | Authentifizierung | Besonderheit |
@@ -164,12 +294,28 @@ Firmen-Gitea nebeneinander.
| --- | --- | | --- | --- |
| Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität | | Repository | Voller Pfad, Beschreibung, Default-Branch, Sichtbarkeit, Größe, letzte Aktivität |
| Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests | | Kennzahlen | Stars, Forks, offene Issues, offene Pull-/Merge-Requests |
| **Pull-/Merge-Requests** | bis zu 50 offene, mit Nummer, Titel, Autor, Alter, Labels, Entwurfskennzeichnung und Link |
| **Issues** | bis zu 50 offene, gleiche Angaben. Bei GitHub und Gitea werden PRs herausgefiltert, die dort in derselben Liste stecken |
| **CI-Status** | Letzter Lauf auf dem Default-Branch: erfolgreich, fehlgeschlagen, läuft, wartet oder abgebrochen — mit Name und Link |
| Branches | bis zu 50, mit SHA, Default-Markierung, Schutzstatus und (wo die API es liefert) letztem Commit | | 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 | | Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15, mit Kurz-SHA, Nachricht, Autor, Datum und Link | | Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten | | Sprachen | Verteilung als Balken mit Prozentwerten |
Woher der CI-Status kommt:
| System | Quelle |
| --- | --- |
| GitHub | `actions/runs` für den Default-Branch; Lauf-Status und Ergebnis werden zusammengeführt |
| GitLab | `pipelines` für den Default-Branch |
| Gitea | Kombinierter Commit-Status — deckt Gitea Actions **und** externe Systeme wie Woodpecker ab, anders als ein reiner Actions-Endpunkt, den ältere Instanzen nicht kennen |
Zur Ehrlichkeit der Aktivitätskurve: geholt wird eine Seite mit 100 Commits. Reicht die
nicht über den ganzen Zeitraum, wird die Kurve auf den tatsächlich belegten Ausschnitt
gekürzt — eine Null zu zeichnen, wo wir schlicht nichts wissen, wäre falsch.
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt - **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar. ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
@@ -251,6 +397,75 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
--- ---
## 4b. Benachrichtigungen
Einzurichten unter *Mein Konto → Benachrichtigungen*, pro Konto und beliebig viele Ziele.
### Anlässe
| Anlass | Wann |
| --- | --- |
| ⚠️ **Abgleich fehlgeschlagen** | Das Git-System war nicht erreichbar oder hat abgelehnt |
| 🔴 **CI rot** | Der letzte Lauf auf dem Default-Branch ist fehlgeschlagen |
| 🏷️ **Neues Release** | Ein neuer Tag oder ein neues Release ist aufgetaucht |
| 🕸️ **Projekt steht still** | Seit längerem kein Commit — Schwelle einstellbar, Vorgabe 60 Tage |
### Nur bei Änderung
Der entscheidende Punkt: Gemeldet wird **ausschließlich, wenn sich etwas ändert**
gegenüber dem, worüber zuletzt benachrichtigt wurde. Ein Repository mit abgelaufenem
Token meldet sich also **einmal**, nicht bei jedem stündlichen Durchlauf. Wird der Fehler
ein anderer, kommt eine neue Meldung; ist er behoben und tritt später erneut auf, ebenso.
Zwei Feinheiten:
- **Beim allerersten Abgleich** eines Repositories wird kein Release gemeldet — sonst
wäre jede bereits vorhandene Version eine „Neuigkeit".
- Der Merkstand wird auch dann fortgeschrieben, wenn **kein** Ziel eingerichtet ist. Sonst
käme beim ersten eingerichteten Ziel die ganze Vergangenheit hinterher.
### Stillstand
Der einzige Anlass, der an keinem Ereignis hängt — es passiert ja gerade nichts. Deshalb
ein eigener Durchlauf, **höchstens einmal am Tag**, unabhängig vom automatischen Abgleich.
Hört niemand auf diesen Anlass, läuft die Prüfung gar nicht erst.
Maßstab ist ausschließlich der **letzte Commit** im verknüpften Repository. Das
Änderungsdatum des Projekts taugt nicht: jeder Abgleich fasst es an und würde den
Stillstand immer wieder zurücksetzen. Projekte **ohne Repository** bleiben deshalb außen
vor, ebenso fertige und archivierte.
Gemeldet wird je Projekt einmal. Erneut erst, wenn danach wieder ein Commit kam und es
seitdem wieder still geworden ist — ein Projekt, das aufwacht und wieder einschläft, meldet
sich also ein zweites Mal.
Die Schwelle setzt ein Administrator unter *Admin-Einstellungen → Stillstand* (14 Tage bis
ein Jahr). Eine Änderung wirkt sofort, nicht erst nach 24 Stunden.
### Zustellwege
| Weg | Details |
| --- | --- |
| **ntfy** | Vollständige Themen-URL. Titel, Priorität und Symbol als Kopfzeilen; optionales Bearer-Token für geschützte Themen. Läuft auch gegen ein selbst gehostetes ntfy im eigenen Netz. |
| **Webhook** | JSON per POST mit Anlass, Titel, Text, Projektbezug und Tieflink. Mit hinterlegtem Geheimnis wird der Rumpf per HMAC-SHA256 signiert (`X-Nebula-Signature`), damit der Empfänger die Herkunft prüfen kann. |
| **E-Mail** | Über SMTP. Steht erst zur Verfügung, wenn `SMTP_HOST` in der `.env` gesetzt ist — sonst ist die Auswahl in der Oberfläche gesperrt und erklärt, warum. |
Jedes Ziel lässt sich einzeln **testen** und **pausieren**; Ergebnis und Zeitpunkt der
letzten Zustellung stehen an der Karte.
### Absicherung
- Zieladressen sind Benutzereingaben, die der Server abruft — sie laufen deshalb durch
denselben **SSRF-Schutz** wie die Git-Verbindungen. Ein ntfy im eigenen Netz bleibt
erlaubt, Loopback und Cloud-Metadaten nicht.
- Redirects werden **nicht verfolgt**: ein Umleitungsziel wäre nicht geprüft.
- Geheimnisse liegen AES-256-GCM-verschlüsselt und verlassen den Server nie; die
Oberfläche zeigt nur einen Hinweis wie `test…mnis`.
- Eine fehlgeschlagene Zustellung lässt den Abgleich nicht scheitern — der Fehler landet
am Ziel, nicht im Sync.
---
## 5. Oberfläche ## 5. Oberfläche
- **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines - **Dark Glassmorphism**: milchige Glasflächen, driftende Nebel im Hintergrund, feines
@@ -262,6 +477,14 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
Fokusring, `prefers-reduced-motion` schaltet Animationen ab. Fokusring, `prefers-reduced-motion` schaltet Animationen ab.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
--- ---
@@ -275,11 +498,17 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
| `Session` | Serverseitige Sitzungen (nur der Token-Hash) | | `Session` | Serverseitige Sitzungen (nur der Token-Hash) |
| `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler | | `Credential` | Passkeys inkl. öffentlichem Schlüssel und Signaturzähler |
| `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges | | `WebAuthnChallenge` | Kurzlebige, einmalig verwendbare Challenges |
| `RecoveryCode` | Wiederherstellungscodes für die Zwei-Faktor-Anmeldung (nur der Hash) |
| `PendingLogin` | Zwischenzustand zwischen Passwort und zweitem Faktor, fünf Minuten gültig |
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Repo-Verknüpfung, Besitzer | | `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels und Issue-Herkunft |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
| `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer | | `GitConnection` | Git-Systeme inkl. verschlüsseltem Token, Besitzer |
| `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Projekt) | | `RepoSnapshot` | Zwischengespeicherter Repo-Stand (1:1 zu Repository) |
`Project.ownerId` und `GitConnection.ownerId` sind bewusst optional: so überleben Daten aus `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 der Zeit vor der Benutzerverwaltung die Migration und werden beim Anlegen des ersten Kontos
@@ -328,7 +557,58 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
- **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`. - **Setup-Skript** erzeugt die `.env` samt zufälligem `APP_SECRET`.
- **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an. - **Seed-Daten** auf Wunsch, idempotent — mehrfaches Ausführen legt nichts doppelt an.
### 6.4 API ### 6.4 Tests
Zwei Ebenen. `npm run test:all` führt beide aus.
**`npm test` — Unit-Tests** (Vitest, 56 Stück, unter einer Sekunde, ohne DOM und ohne
Datenbank):
| Bereich | Was geprüft wird |
| --- | --- |
| `utils` | Slug-Erzeugung inklusive Umlauten und Längenbegrenzung, Tag-Aufbereitung, Kürzen, Zahlenformat |
| `git/parse` | URL-Zerlegung für https, SSH, verschachtelte GitLab-Gruppen und Oberflächenpfade; Provider-Erkennung; Bildung der Aktivitätskurve |
| `security/ssrf` | Sperrlisten für Loopback, Link-Local, Metadaten-Endpunkt, CGNAT und Multicast; Formprüfung; die drei Umgebungsschalter |
| `auth/password` | scrypt-Rundlauf, unterschiedliches Salz, Ablageformat, Verhalten bei kaputten Hashes, Passwortregeln |
| `tasks` | Berechnung des abgeleiteten Fortschritts, Spaltenreihenfolge |
| `notify/pending` | Wann eine Benachrichtigung fällig ist: neuer Fehler ja, gleicher Fehler nein; CI-Wechsel nach rot; Release erst ab dem zweiten Abgleich |
| `notify/stale` | Stillstandsschwelle, keine Zweitmeldung, aber erneute Meldung nach zwischenzeitlicher Aktivität |
| `validation` | Dass jeder Anlass, den die Oberfläche anbietet, auch vom Schema akzeptiert wird — entstanden aus genau diesem Fehler |
**`npm run test:e2e` — Ende-zu-Ende** (Playwright, 13 Stück, rund 20 Sekunden). Läuft
gegen die gebaute Anwendung mit eigener Datenbank auf Port 3111 — also durch Middleware,
Authentifizierung, Validierung und Datenbank hindurch:
| Datei | Was geprüft wird |
| --- | --- |
| `e2e/api.spec.ts` | Einrichtung sperrt sich nach dem ersten Konto · kein Zugriff ohne Anmeldung · fremdes Origin und falscher Content-Type abgewiesen · SSRF geblockt · **Datentrennung zwischen zwei Konten** · Aufgaben-Reihenfolge, abgeleiteter Fortschritt und untergeschobene IDs · Export ohne Geheimnisse, Import je Strategie |
| `e2e/ui.spec.ts` | Anmelden · Projekt anlegen · Aufgaben anlegen und **per Ziehen verschieben** (inklusive Prüfung nach dem Neuladen) · Volltextsuche · Abmelden sperrt geschützte Seiten wieder |
Zwei Dinge sind bewusst so gebaut:
- **Die Testdatenbank kann nicht die echte sein.** `e2e/global-setup.ts` bricht ab, wenn
der Datenbankname nicht `test` enthält — vor dem `TRUNCATE`. Der teuerste denkbare
Fehler wäre eine Testreihe, die die Produktivdaten leert.
- **Playwright startet den Server selbst** (`webServer` in `playwright.config.ts`) mit
eigener `DATABASE_URL` und eigenem Port. Eine laufende Instanz auf 3000 stört nicht.
Einmalig vorbereiten:
```bash
docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
```
### 6.5 Fortlaufende Prüfung
`.gitea/workflows/ci.yml` prüft jeden Push und jeden Pull Request: Typen, Unit-Tests,
Bau und Ende-zu-Ende-Tests gegen einen frischen PostgreSQL-Dienst. Schlägt etwas fehl,
wird der Playwright-Bericht als Artefakt abgelegt.
Dafür müssen in der Gitea-Instanz Actions aktiviert sein (`app.ini`: `[actions] ENABLED
= true`) und ein Runner registriert. Die Syntax ist GitHub-kompatibel — für GitHub die
Datei nach `.github/workflows/` kopieren.
### 6.6 API
Zehn REST-Endpunkte für Projekte, Notizen, Verbindungen, Sync, Repo-Listen und Health. 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 Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
+23 -1
View File
@@ -127,6 +127,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
| `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer | | `GIT_ALLOWED_HOSTS` | Strengster Modus: nur diese Hosts erlauben | leer |
| `NOTIFY_TIMEOUT_MS` | Timeout beim Zustellen von Benachrichtigungen | `10000` |
| `SMTP_HOST` … `SMTP_FROM` | SMTP-Zugang; ohne `SMTP_HOST` steht der Weg „E-Mail" nicht zur Verfügung | leer |
`APP_NAME`, `APP_TAGLINE` und `APP_URL` werden **zur Laufzeit** gelesen. Dasselbe `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 Docker-Image lässt sich also ohne Neubau umbenennen oder unter einer anderen Adresse
@@ -298,7 +300,12 @@ nebula/
| `npm run db:seed` | Beispielprojekte anlegen | | `npm run db:seed` | Beispielprojekte anlegen |
| `npm run db:studio` | Prisma Studio öffnen | | `npm run db:studio` | Prisma Studio öffnen |
| `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) | | `npm run db:reset` | Datenbank zurücksetzen (**löscht alles**) |
| `npm test` | Unit-Tests (Vitest), unter einer Sekunde |
| `npm run test:watch` | Tests im Beobachtungsmodus |
| `npm run typecheck` | TypeScript prüfen | | `npm run typecheck` | TypeScript prüfen |
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten | | `npm run docker:db` | Nur die Datenbank starten |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen | | `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` | | `npm run docker:tools` | Adminer auf `http://localhost:8080` |
@@ -317,12 +324,15 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) | | `GET` | `/api/health` | Status inkl. Datenbankverbindung (Docker-Healthcheck) |
| `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) | | `POST` | `/api/auth/setup` | Administratorkonto anlegen (nur beim ersten Mal) |
| `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung | | `POST` | `/api/auth/login` `/api/auth/logout` `/api/auth/register` | Anmeldung |
| `POST` | `/api/auth/mfa` | Zweiter Faktor: Einmalkennwort oder Wiederherstellungscode |
| `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand | | `GET` | `/api/auth/session` | Aktuelle Sitzung und Instanzzustand |
| `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn | | `POST` | `/api/auth/passkey/{login,register}/{options,verify}` | WebAuthn |
| `PATCH` | `/api/account/profile` | Eigenes Profil | | `PATCH` | `/api/account/profile` | Eigenes Profil |
| `POST` | `/api/account/password` | Eigenes Passwort setzen | | `POST` | `/api/account/password` | Eigenes Passwort setzen |
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden | | `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen | | `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
| `GET` `POST` `PUT` `DELETE` | `/api/account/totp` | Zwei-Faktor: Zustand, einrichten, aktivieren, abschalten |
| `POST` | `/api/account/totp/recovery` | Neue Wiederherstellungscodes |
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) | | `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) | | `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) | | `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
@@ -332,8 +342,20 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) | | `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau | | `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) | | `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen | | `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Repo-Daten vom Git-System holen | | `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen |
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
| `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen |
| `POST` | `/api/projects/:id/tasks/from-issues` | Offene Issues als Aufgaben übernehmen |
| `PATCH` `DELETE` | `/api/tasks/:id` | Aufgabe ändern / löschen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts | | `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen | | `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen | | `GET` `POST` | `/api/connections` | Git-Verbindungen |
+20 -82
View File
@@ -11,101 +11,33 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
## 1 · Als Nächstes empfohlen ## 1 · Als Nächstes empfohlen
### 1.1 Markdown in Beschreibung und Notizen `S` ### 1.1 Webhooks statt Polling `M`
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
werden klickbar.
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
### 1.2 Volltextsuche über Notizen `S`
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
zusätzlichen Dienst.
### 1.3 Kanban-Board mit Drag & Drop `M`
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
bleibt die Listenansicht.
---
## 2 · Danach
### 2.1 Offene Pull Requests und Issues als Liste `S`
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
nützlicher — die Daten holen wir ohnehin schon ab.
### 2.2 CI-Status je Branch `M`
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
Endpunkt und einen zusätzlichen Token-Scope.
### 2.3 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung. 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 ## 2 · Danach
da, es fehlt nur die Darstellung.
### 2.5 Aufgaben und Checklisten `M` ### 2.1 Commit-Aktivität als Heatmap `S`
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers. wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 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 · Größere Brocken
### 3.1 Automatisierte Tests und CI `L` ### 3.1 Passwort-Zurücksetzen per E-Mail `M`
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 Zwischen­tabelle
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 Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.5 Mehrsprachigkeit `L` ### 3.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst. Voraussetzung, falls du das Projekt veröffentlichen willst.
@@ -116,15 +48,12 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal | | Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen | | Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste | | 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 | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
@@ -156,6 +85,15 @@ Damit klar ist, was **nicht** kommt und warum:
| Docker- und Standalone-Betrieb | ✓ | | Docker- und Standalone-Betrieb | ✓ |
| Automatischer Abgleich mit Zeitplan | ✓ | | Automatischer Abgleich mit Zeitplan | ✓ |
| Export, Import und Sicherung | ✓ | | Export, Import und Sicherung | ✓ |
| **v0.3.0** Markdown, Volltextsuche über Notizen, PR- und Issue-Listen, Testfundament | ✓ |
| **v0.4.0** Kanban mit Drag & Drop, CI-Status je Branch, Commit-Aktivität | ✓ |
| **v0.5.0** Aufgabenbrett je Projekt mit sortierbaren Spalten, Fortschritt aus Aufgaben | ✓ |
| **v0.6.0** Ende-zu-Ende-Tests mit Playwright, CI-Pipeline für Gitea Actions | ✓ |
| **v0.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ |
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
+7
View File
@@ -59,6 +59,13 @@ services:
APP_URL: ${APP_URL:-http://localhost:3000} APP_URL: ${APP_URL:-http://localhost:3000}
SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30}
GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000} GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000}
NOTIFY_TIMEOUT_MS: ${NOTIFY_TIMEOUT_MS:-10000}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
GIT_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false} GIT_ALLOW_LOOPBACK: ${GIT_ALLOW_LOOPBACK:-false}
GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false} GIT_BLOCK_PRIVATE: ${GIT_BLOCK_PRIVATE:-false}
GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-} GIT_ALLOWED_HOSTS: ${GIT_ALLOWED_HOSTS:-}
+456
View File
@@ -0,0 +1,456 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
/**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
* Authentifizierung, Validierung und Datenbank hindurch. Genau die Dinge,
* die Unit-Tests nicht erreichen.
*
* Die Tests bauen aufeinander auf und laufen in dieser Reihenfolge, weil sie
* sich eine Datenbank teilen (siehe playwright.config.ts, workers: 1).
*/
const ADMIN = { username: "chefin", password: "ein-langes-testpasswort" };
const ZWEITER = { username: "zweiter", password: "noch-ein-langes-passwort" };
async function login(request: APIRequestContext, user: { username: string; password: string }) {
const response = await request.post("/api/auth/login", { data: user });
expect(response.ok(), `Anmeldung als ${user.username}`).toBeTruthy();
}
test.describe.configure({ mode: "serial" });
test("Einrichtung legt das erste Konto an und sperrt sich danach", async ({ request }) => {
const response = await request.post("/api/auth/setup", {
data: { ...ADMIN, mode: "MULTI_USER" },
});
expect(response.status()).toBe(200);
expect((await response.json()).user.role).toBe("ADMIN");
// Ein zweiter Aufruf darf kein weiteres Adminkonto erzeugen.
const wieder = await request.post("/api/auth/setup", {
data: { username: "eindringling", password: "ein-langes-testpasswort" },
});
expect(wieder.status()).toBe(409);
});
test("ohne Anmeldung ist nichts zu holen", async ({ playwright }) => {
const anonym = await playwright.request.newContext({ baseURL: test.info().project.use.baseURL });
for (const pfad of ["/api/projects", "/api/connections", "/api/admin/users"]) {
expect((await anonym.get(pfad)).status(), pfad).toBe(401);
}
await anonym.dispose();
});
test("Fremdes Origin wird abgewiesen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
data: { name: "Von woanders" },
headers: { Origin: "https://boese.example" },
});
expect(response.status()).toBe(403);
});
test("Content-Type wird erzwungen", async ({ request }) => {
await login(request, ADMIN);
const response = await request.post("/api/projects", {
headers: { "Content-Type": "text/plain" },
data: JSON.stringify({ name: "Umgehung" }),
});
expect(response.status()).toBe(415);
});
test("SSRF: interne Adressen werden geblockt", async ({ request }) => {
await login(request, ADMIN);
for (const ziel of ["http://127.0.0.1:5432", "http://169.254.169.254", "http://0.0.0.0"]) {
const response = await request.post("/api/connections", {
data: { name: `ssrf-${ziel}`, provider: "GITEA", baseUrl: ziel },
});
expect(response.status(), ziel).toBe(422);
}
});
test("Datentrennung zwischen zwei Konten", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = await request.post("/api/projects", {
data: { name: "Geheimprojekt", status: "IN_DEVELOPMENT" },
});
expect(angelegt.status()).toBe(201);
const projektId = (await angelegt.json()).project.id;
const aufgabe = await request.post(`/api/projects/${projektId}/tasks`, {
data: { title: "Nicht fuer fremde Augen" },
});
expect(aufgabe.status()).toBe(201);
const aufgabenId = (await aufgabe.json()).task.id;
// Zweites Konto anlegen und in einem eigenen Kontext anmelden.
const neu = await request.post("/api/admin/users", {
data: { ...ZWEITER, role: "USER" },
});
expect(neu.status()).toBe(201);
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/projects")).json()).projects).toHaveLength(0);
// Fremde IDs muessen 404 liefern, nicht 403 – sonst waere die Existenz
// des Datensatzes bestaetigt.
expect((await fremd.get(`/api/projects/${projektId}`)).status()).toBe(404);
expect(
(await fremd.patch(`/api/projects/${projektId}`, { data: { name: "uebernommen" } })).status(),
).toBe(404);
expect((await fremd.delete(`/api/projects/${projektId}`)).status()).toBe(404);
expect((await fremd.get(`/api/projects/${projektId}/tasks`)).status()).toBe(404);
expect((await fremd.patch(`/api/tasks/${aufgabenId}`, { data: { title: "x" } })).status()).toBe(
404,
);
// Adminwege bleiben zu.
expect((await fremd.get("/api/admin/users")).status()).toBe(403);
expect(
(await fremd.patch("/api/admin/settings", { data: { mode: "SINGLE_USER" } })).status(),
).toBe(403);
// Und das Original ist unveraendert.
const danach = await (await request.get(`/api/projects/${projektId}`)).json();
expect(danach.project.name).toBe("Geheimprojekt");
expect(danach.project.tasks).toHaveLength(1);
await fremd.dispose();
});
test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Aufgabenprojekt" } })).json()
).project;
const titel = ["eins", "zwei", "drei"];
const ids: string[] = [];
for (const title of titel) {
const response = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title, status: "TODO" },
});
ids.push((await response.json()).task.id);
}
// Neue Aufgaben haengen sich hinten an.
const angelegt = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(angelegt.map((task: { position: number }) => task.position)).toEqual([0, 1, 2]);
// Umdrehen.
const gedreht = [...ids].reverse();
const reorder = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { TODO: gedreht } },
});
expect(reorder.ok()).toBeTruthy();
const nachher = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(nachher.map((task: { title: string }) => task.title)).toEqual(["drei", "zwei", "eins"]);
// Eine nach DONE – Fortschritt folgt, sobald eingeschaltet.
await request.patch(`/api/projects/${projekt.id}`, { data: { progressFromTasks: true } });
await request.patch(`/api/tasks/${ids[0]}`, { data: { status: "DONE" } });
const mitFortschritt = (
await (await request.get(`/api/projects/${projekt.id}`)).json()
).project;
expect(mitFortschritt.progress).toBe(33);
// Eine ID aus einem anderen Projekt darf nicht verschoben werden.
const anderes = (
await (await request.post("/api/projects", { data: { name: "Anderes" } })).json()
).project;
const fremdeAufgabe = (
await (
await request.post(`/api/projects/${anderes.id}/tasks`, { data: { title: "fremd" } })
).json()
).task;
const untergeschoben = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DOING: [fremdeAufgabe.id] } },
});
const ergebnis = await untergeschoben.json();
expect(ergebnis.updated).toBe(0);
expect(ergebnis.ignored).toBe(1);
const unveraendert = (
await (await request.get(`/api/projects/${anderes.id}/tasks`)).json()
).tasks[0];
expect(unveraendert.status).toBe("TODO");
});
test("Issues als Aufgaben uebernehmen, ohne Dubletten", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Issue-Projekt" } })).json()
).project;
// Ohne verknuepftes Repository gibt es nichts zu uebernehmen.
const ohneRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneRepo.status()).toBe(409);
// Mit Repository, aber ohne geholten Stand ebenfalls nicht – der Server
// laedt hier bewusst nichts nach.
await request.patch(`/api/projects/${projekt.id}`, {
data: { repoUrl: "https://github.com/vercel/swr" },
});
const ohneStand = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: {},
});
expect(ohneStand.status()).toBe(409);
// Nach einem bestimmten, das es nicht gibt, ist dagegen 404 richtig.
const fremdesRepo = await request.post(`/api/projects/${projekt.id}/tasks/from-issues`, {
data: { repositoryId: "gibt-es-nicht" },
});
expect(fremdesRepo.status()).toBe(404);
});
test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Beim Anlegen setzen die Git-Felder das fuehrende Repository.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Zwei Teile", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
expect(projekt.repositories).toHaveLength(1);
expect(projekt.repositories[0].repoOwner).toBe("vercel");
expect(projekt.repositories[0].repoName).toBe("swr");
// Ein zweites dazu.
const zweites = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Backend", repoUrl: "https://github.com/vercel/next.js" },
});
expect(zweites.status()).toBe(201);
const backendId = (await zweites.json()).repository.id;
// Dasselbe Repo zweimal ist immer ein Versehen.
const doppelt = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { repoUrl: "https://github.com/vercel/next.js" },
});
expect(doppelt.status()).toBe(409);
// Halbe Angaben ergeben kein Repository.
const leer = await request.post(`/api/projects/${projekt.id}/repositories`, {
data: { label: "Nur ein Name" },
});
expect(leer.status()).toBe(422);
const geladen = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(geladen.repositories).toHaveLength(2);
// Das fuehrende steht vorn.
expect(geladen.repositories[0].repoName).toBe("swr");
expect(geladen.repositories[1].label).toBe("Backend");
// Der Projekt-PATCH bearbeitet weiterhin nur das fuehrende.
await request.patch(`/api/projects/${projekt.id}`, { data: { autoSync: false } });
const danach = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(danach.repositories[0].autoSync).toBe(false);
expect(danach.repositories[1].autoSync).toBe(true);
// Fremde Konten kommen an kein Repository.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.get(`/api/projects/${projekt.id}/repositories`)).status()).toBe(404);
expect((await fremd.patch(`/api/repositories/${backendId}`, { data: {} })).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${backendId}`)).status()).toBe(404);
expect((await fremd.post(`/api/repositories/${backendId}/sync`)).status()).toBe(404);
await fremd.dispose();
// Entfernen laesst das andere stehen.
expect((await request.delete(`/api/repositories/${backendId}`)).ok()).toBeTruthy();
const zumSchluss = (await (await request.get(`/api/projects/${projekt.id}`)).json()).project;
expect(zumSchluss.repositories).toHaveLength(1);
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN);
await request.post("/api/connections", {
data: {
name: "Mit Token",
provider: "GITEA",
baseUrl: "https://gitea.example.com",
token: "streng-geheimes-token",
},
});
const roh = await (await request.get("/api/projects/export")).text();
expect(roh).not.toContain("streng-geheimes-token");
expect(roh).not.toContain("tokenCipher");
expect(roh).not.toContain("ownerId");
const datei = JSON.parse(roh);
const vorher = datei.projects.length;
// Version 2: die Repositories stehen als Liste in der Datei.
expect(datei.version).toBe(2);
const mitRepo = datei.projects.find((entry: { name: string }) => entry.name === "Zwei Teile");
expect(mitRepo.repositories).toHaveLength(1);
expect(mitRepo.repositories[0].repoName).toBe("swr");
// Ueberspringen aendert nichts.
const uebersprungen = await request.post("/api/projects/import", {
data: { data: datei, strategy: "skip", dryRun: false },
});
expect((await uebersprungen.json()).summary.skipped).toBe(vorher);
// Zusaetzlich anlegen verdoppelt.
const zusaetzlich = await request.post("/api/projects/import", {
data: { data: datei, strategy: "rename", dryRun: false },
});
expect((await zusaetzlich.json()).summary.renamed).toBe(vorher);
const jetzt = (await (await request.get("/api/projects")).json()).projects;
expect(jetzt).toHaveLength(vorher * 2);
// Muell wird abgewiesen.
const muell = await request.post("/api/projects/import", {
data: { data: { irgendwas: true }, strategy: "skip", dryRun: true },
});
expect(muell.status()).toBe(422);
});
test("Dateien der Version 1 bleiben lesbar", async ({ request }) => {
await login(request, ADMIN);
// Damals stand genau ein Repository flach am Projekt. Wer eine solche
// Sicherung liegen hat, soll sie weiterhin einspielen koennen.
const alt = {
kind: "nebula.projects",
version: 1,
projects: [
{
name: "Aus Version eins",
repoUrl: "https://github.com/vercel/swr",
repoOwner: "vercel",
repoName: "swr",
autoSync: false,
},
],
};
const gelesen = await request.post("/api/projects/import", {
data: { data: alt, strategy: "rename", dryRun: false },
});
expect(gelesen.ok()).toBeTruthy();
const projekte = (await (await request.get("/api/projects")).json()).projects;
const uebernommen = projekte.find(
(entry: { name: string }) => entry.name === "Aus Version eins",
);
expect(uebernommen.repositories).toHaveLength(1);
expect(uebernommen.repositories[0].repoOwner).toBe("vercel");
expect(uebernommen.repositories[0].autoSync).toBe(false);
});
+62
View File
@@ -0,0 +1,62 @@
import { execFileSync } from "node:child_process";
import { PrismaClient } from "@prisma/client";
/**
* Bereitet die Testdatenbank vor: Schema einspielen und leeren.
*
* Die Datenbank muss vorher existieren. Anlegen ist bewusst nicht Teil des
* Setups – in CI erzeugt sie der Postgres-Dienst, oertlich einmalig:
*
* docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_e2e_test"
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/**
* Reihenfolge egal, weil CASCADE – aber vollstaendig muss sie sein, sonst
* schleppen Laeufe Reste mit.
*/
const TABLES = [
"Task",
"Note",
"RepoSnapshot",
"Project",
"Credential",
"Session",
"WebAuthnChallenge",
"GitConnection",
"User",
"AppSettings",
];
export default async function globalSetup() {
const name = new URL(DATABASE_URL).pathname.replace(/^\//, "");
// Schutz vor dem teuersten aller Fehler: eine Testreihe, die die echte
// Datenbank leert. Der Name muss "test" enthalten, sonst passiert nichts.
if (!name.includes("test")) {
throw new Error(
`Die Testdatenbank heisst "${name}" und enthaelt nicht "test". ` +
"Aus Sicherheitsgruenden abgebrochen – E2E_DATABASE_URL pruefen.",
);
}
console.log(`[e2e] Datenbank: ${name}`);
execFileSync("npx", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL },
stdio: "pipe",
shell: process.platform === "win32",
});
const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } });
try {
const list = TABLES.map((table) => `"${table}"`).join(", ");
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${list} RESTART IDENTITY CASCADE`);
console.log("[e2e] Datenbank geleert.");
} finally {
await prisma.$disconnect();
}
}
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
/**
* Ein Weg durch die Oberflaeche, wie ihn ein Mensch geht: anmelden, Projekt
* anlegen, Aufgaben verwalten. Keine Vollabdeckung – die Wege, die kaputt
* gehen duerfen, ohne dass es jemand merkt.
*
* Setzt voraus, dass api.spec.ts das Konto bereits angelegt hat; laeuft
* alphabetisch danach.
*/
const KONTO = { username: "chefin", password: "ein-langes-testpasswort" };
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.fill("#login-username", KONTO.username);
await page.fill("#login-password", KONTO.password);
await page.click('button[type="submit"]');
await expect(page.getByText("Vibe Coding Kontrollzentrum")).toBeVisible();
});
test("Dashboard zeigt Kennzahlen und Projekte", async ({ page }) => {
await expect(page.getByRole("heading", { name: /Deine Projekte/ })).toBeVisible();
await expect(page.getByText("Projekte", { exact: false }).first()).toBeVisible();
// Keine unbeabsichtigten Fehler in der Konsole.
expect(page.url()).toContain("/");
});
test("Projekt anlegen und wiederfinden", async ({ page }) => {
await page.getByRole("button", { name: /Neues Projekt/ }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#project-name", "Oberflaechentest");
await page.fill("#project-summary", "Von Playwright angelegt.");
await page.getByRole("button", { name: "Projekt anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
await expect(page.getByText("Oberflaechentest")).toBeVisible();
});
test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Zwei Aufgaben ueber den Dialog anlegen.
for (const titel of ["Erste Aufgabe", "Zweite Aufgabe"]) {
await page.getByRole("button", { name: "Aufgabe", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.fill("#task-title", titel);
await page.getByRole("button", { name: "Anlegen" }).click();
await expect(page.getByRole("dialog")).toBeHidden();
}
const offen = page.locator('[data-task-column="TODO"]');
const inArbeit = page.locator('[data-task-column="DOING"]');
await expect(offen.getByText("Erste Aufgabe")).toBeVisible();
await expect(offen.getByText("Zweite Aufgabe")).toBeVisible();
// Erste Aufgabe nach "In Arbeit" ziehen.
const griff = offen.locator('button[aria-label$="verschieben"]').first();
const von = await griff.boundingBox();
const nach = await inArbeit.boundingBox();
expect(von && nach).toBeTruthy();
await page.mouse.move(von!.x + von!.width / 2, von!.y + von!.height / 2);
await page.mouse.down();
// Erst ueber die Aktivierungsschwelle des Sensors (5 Pixel) hinaus.
await page.mouse.move(von!.x + 30, von!.y + 10, { steps: 6 });
await page.mouse.move(nach!.x + nach!.width / 2, nach!.y + 60, { steps: 20 });
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert.
await page.reload();
await expect(
page.locator('[data-task-column="DOING"]').getByText("Erste Aufgabe"),
).toBeVisible();
});
test("Volltextsuche findet Notizen", async ({ page }) => {
await page.getByRole("link", { name: /Projekt Oberflaechentest/ }).click();
await page.getByRole("button", { name: "Notiz", exact: true }).click();
await page.getByPlaceholder(/Gedanken/).fill("Suchbegriff Zaunkoenig steht hier.");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Zaunkoenig", { exact: false })).toBeVisible();
// exact, sonst trifft es auch "Zurueck zum Dashboard".
await page.getByRole("link", { name: "Dashboard", exact: true }).click();
await page.getByPlaceholder(/durchsuchen/).fill("Zaunkoenig");
await expect(page.getByText("Treffer in Notizen")).toBeVisible();
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
// Ohne Eingabe stehen Projekte und Bereiche bereit.
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig");
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
// Enter springt zum ersten Treffer.
await page.keyboard.press("Enter");
await expect(page.getByRole("heading", { name: "Oberflaechentest" })).toBeVisible();
// Escape schliesst wieder.
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
});
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click();
await expect(page).toHaveURL(/\/login/);
// Und die geschuetzte Seite ist wieder zu.
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
});
+421 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.1.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "0.1.0", "version": "1.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -17,6 +17,8 @@
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.3",
"qrcode": "^1.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -25,10 +27,14 @@
"zod": "^3.25.28" "zod": "^3.25.28"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21", "@types/node": "^22.15.21",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
@@ -1513,6 +1519,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -2302,6 +2324,26 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -2446,6 +2488,30 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/asn1js": { "node_modules/asn1js": {
"version": "3.0.10", "version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -2509,6 +2575,28 @@
} }
} }
}, },
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001806", "version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
@@ -2621,6 +2709,35 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2678,6 +2795,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": { "node_modules/decode-named-character-reference": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -2747,11 +2873,17 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"devOptional": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -2771,6 +2903,12 @@
"fast-check": "^3.23.1" "fast-check": "^3.23.1"
} }
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/empathic": { "node_modules/empathic": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
@@ -2940,6 +3078,19 @@
} }
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2955,6 +3106,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/giget": { "node_modules/giget": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
@@ -3085,6 +3245,15 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-hexadecimal": { "node_modules/is-hexadecimal": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
@@ -3390,6 +3559,18 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4373,6 +4554,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nypm": { "node_modules/nypm": {
"version": "0.6.8", "version": "0.6.8",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
@@ -4419,6 +4609,42 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parse-entities": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@@ -4444,6 +4670,15 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -4489,6 +4724,61 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.23", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -4589,6 +4879,23 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/rc9": { "node_modules/rc9": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -4748,6 +5055,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -4801,6 +5123,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
@@ -4886,6 +5214,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stringify-entities": { "node_modules/stringify-entities": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -4900,6 +5242,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.21", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -5373,6 +5727,12 @@
} }
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -5390,6 +5750,61 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+11 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.4.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
@@ -25,10 +25,10 @@
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
}, "test:e2e": "npm run build && playwright test",
"prisma": { "test:e2e:ui": "playwright test --ui",
"seed": "tsx prisma/seed.ts" "test:all": "npm test && npm run test:e2e"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -39,6 +39,8 @@
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.3",
"qrcode": "^1.5.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -47,10 +49,14 @@
"zod": "^3.25.28" "zod": "^3.25.28"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@types/node": "^22.15.21", "@types/node": "^22.15.21",
"@types/nodemailer": "^8.0.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
+63
View File
@@ -0,0 +1,63 @@
import { defineConfig } from "@playwright/test";
/**
* Ende-zu-Ende-Tests gegen die echte Anwendung.
*
* Playwright startet den Produktionsserver selbst und richtet ihn auf eine
* eigene Datenbank. Der Port ist bewusst ein anderer als 3000 und 3100,
* damit eine laufende Instanz nicht stoert.
*
* Voraussetzung: ein Bau (`npm run build`) und eine leere Datenbank, deren
* Name "test" enthaelt – siehe global-setup.ts. Beides erledigt
* `npm run test:e2e`.
*/
const PORT = Number(process.env.E2E_PORT ?? 3111);
const BASE_URL = `http://localhost:${PORT}`;
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
export default defineConfig({
testDir: "./e2e",
// Die Tests teilen sich eine Datenbank, deshalb nacheinander.
fullyParallel: false,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
timeout: 60_000,
expect: { timeout: 10_000 },
globalSetup: "./e2e/global-setup.ts",
use: {
baseURL: BASE_URL,
locale: "de-DE",
timezoneId: "Europe/Berlin",
trace: "retain-on-failure",
screenshot: "only-on-failure",
// Oertlich der installierte Chrome, damit kein Browser geladen werden
// muss. In CI gibt es keinen – dort das mitgelieferte Chromium
// (siehe "playwright install chromium" im Arbeitsablauf).
...(process.env.CI ? {} : { channel: "chrome" as const }),
},
webServer: {
command: "npm start",
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
env: {
DATABASE_URL,
PORT: String(PORT),
APP_URL: BASE_URL,
NODE_ENV: "production",
// Fester Schluessel, damit Laeufe vergleichbar bleiben.
APP_SECRET: "e2e-test-secret-nur-fuer-die-testlaeufe-1234567890",
},
},
});
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,16 @@
-- Volltextsuche ueber Notizen.
--
-- Dies ist ein Ausdrucksindex. Prisma kann so etwas im Schema nicht abbilden
-- und kennt ihn deshalb nicht. Nachgeprueft: "prisma migrate diff" laesst ihn
-- in Ruhe und schlaegt kein DROP vor - Ausdrucksindizes werden schlicht
-- ignoriert. Trotzdem beim Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss mit dem in src/app/api/search/route.ts uebereinstimmen,
-- sonst greift der Index nicht. Die zweistellige Form von to_tsvector mit
-- fester Konfiguration ist IMMUTABLE und damit indizierbar.
CREATE INDEX "Note_fulltext_idx" ON "Note"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || "content"));
@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "ciBranch" TEXT,
ADD COLUMN "ciFinishedAt" TIMESTAMP(3),
ADD COLUMN "ciName" TEXT,
ADD COLUMN "ciState" TEXT,
ADD COLUMN "ciUrl" TEXT,
ADD COLUMN "commitActivity" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "issues" JSONB NOT NULL DEFAULT '[]',
ADD COLUMN "pullRequests" JSONB NOT NULL DEFAULT '[]';
@@ -0,0 +1,28 @@
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" "TaskStatus" NOT NULL DEFAULT 'TODO',
"position" INTEGER NOT NULL DEFAULT 0,
"dueDate" TIMESTAMP(3),
"labels" TEXT[] DEFAULT ARRAY[]::TEXT[],
"doneAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,39 @@
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('WEBHOOK', 'NTFY', 'EMAIL');
-- CreateEnum
CREATE TYPE "NotificationEvent" AS ENUM ('SYNC_FAILED', 'CI_FAILED', 'NEW_RELEASE');
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "notifiedCiState" TEXT,
ADD COLUMN "notifiedRelease" TEXT,
ADD COLUMN "notifiedSyncError" TEXT;
-- CreateTable
CREATE TABLE "NotificationTarget" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"target" TEXT NOT NULL,
"secretCipher" TEXT,
"secretHint" TEXT,
"events" "NotificationEvent"[] DEFAULT ARRAY[]::"NotificationEvent"[],
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastSentAt" TIMESTAMP(3),
"lastOk" BOOLEAN,
"lastMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationTarget_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "NotificationTarget_userId_idx" ON "NotificationTarget"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationTarget_userId_name_key" ON "NotificationTarget"("userId", "name");
-- AddForeignKey
ALTER TABLE "NotificationTarget" ADD CONSTRAINT "NotificationTarget_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,9 @@
-- AlterEnum
ALTER TYPE "NotificationEvent" ADD VALUE 'PROJECT_STALE';
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "lastStaleCheckAt" TIMESTAMP(3),
ADD COLUMN "staleAfterDays" INTEGER NOT NULL DEFAULT 60;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "notifiedStaleAt" TIMESTAMP(3);
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceIssue" INTEGER,
ADD COLUMN "sourceUrl" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Task_projectId_sourceIssue_key" ON "Task"("projectId", "sourceIssue");
@@ -0,0 +1,24 @@
-- CreateEnum
CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED');
-- CreateTable
CREATE TABLE "Activity" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"userId" TEXT,
"kind" "ActivityKind" NOT NULL,
"summary" TEXT NOT NULL,
"meta" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt");
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpEnabledAt" TIMESTAMP(3),
ADD COLUMN "totpSecretCipher" TEXT;
-- CreateTable
CREATE TABLE "RecoveryCode" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RecoveryCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PendingLogin" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PendingLogin_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "RecoveryCode_codeHash_key" ON "RecoveryCode"("codeHash");
-- CreateIndex
CREATE INDEX "RecoveryCode_userId_idx" ON "RecoveryCode"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PendingLogin_tokenHash_key" ON "PendingLogin"("tokenHash");
-- CreateIndex
CREATE INDEX "PendingLogin_userId_idx" ON "PendingLogin"("userId");
-- CreateIndex
CREATE INDEX "PendingLogin_expiresAt_idx" ON "PendingLogin"("expiresAt");
-- AddForeignKey
ALTER TABLE "RecoveryCode" ADD CONSTRAINT "RecoveryCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PendingLogin" ADD CONSTRAINT "PendingLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,113 @@
-- Mehrere Repositories je Projekt.
--
-- Die Git-Felder wandern vom Projekt in eine eigene Tabelle. Bestehende
-- Projekte bekommen daraus genau ein Repository, damit nichts verlorengeht;
-- der Snapshot haengt danach am Repository statt am Projekt.
--
-- Von Hand geschrieben: prisma migrate diff wuerde die Spalten am Projekt
-- ersatzlos loeschen und die Snapshots gleich mit.
-- ── 1. Neue Tabelle ─────────────────────────────────────────
CREATE TABLE "Repository" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"label" TEXT,
"repoUrl" TEXT,
"repoOwner" TEXT,
"repoName" TEXT,
"defaultBranch" TEXT,
"connectionId" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"lastSyncedAt" TIMESTAMP(3),
"syncError" TEXT,
"autoSync" BOOLEAN NOT NULL DEFAULT true,
"syncFailureCount" INTEGER NOT NULL DEFAULT 0,
"notifiedSyncError" TEXT,
"notifiedCiState" TEXT,
"notifiedRelease" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Repository_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Repository_projectId_position_idx" ON "Repository"("projectId", "position");
CREATE INDEX "Repository_connectionId_idx" ON "Repository"("connectionId");
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Repository" ADD CONSTRAINT "Repository_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "GitConnection"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- ── 2. Bestand uebernehmen ──────────────────────────────────
-- Nur Projekte, an denen wirklich etwas haengt. Ein Projekt, das bloss eine
-- Verbindung ausgewaehlt hatte, ohne je ein Repo zu nennen, braucht keine
-- leere Zeile.
INSERT INTO "Repository" (
"id", "projectId", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", "position", "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", "updatedAt"
)
SELECT
gen_random_uuid()::text, "id", "repoUrl", "repoOwner", "repoName", "defaultBranch",
"connectionId", 0, "lastSyncedAt", "syncError", "autoSync",
"syncFailureCount", "notifiedSyncError", "notifiedCiState", "notifiedRelease",
"createdAt", CURRENT_TIMESTAMP
FROM "Project"
WHERE "repoUrl" IS NOT NULL
OR ("repoOwner" IS NOT NULL AND "repoName" IS NOT NULL);
-- ── 3. Snapshots umhaengen ──────────────────────────────────
ALTER TABLE "RepoSnapshot" ADD COLUMN "repositoryId" TEXT;
UPDATE "RepoSnapshot" AS s
SET "repositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = s."projectId";
-- Ein Snapshot ohne zugehoeriges Repository kann nur entstanden sein, wenn
-- jemand die Repo-Angaben nachtraeglich geleert hat. Er ist ohnehin nicht
-- mehr zuzuordnen und beim naechsten Abgleich neu geholt.
DELETE FROM "RepoSnapshot" WHERE "repositoryId" IS NULL;
ALTER TABLE "RepoSnapshot" ALTER COLUMN "repositoryId" SET NOT NULL;
ALTER TABLE "RepoSnapshot" DROP CONSTRAINT "RepoSnapshot_projectId_fkey";
DROP INDEX "RepoSnapshot_projectId_key";
ALTER TABLE "RepoSnapshot" DROP COLUMN "projectId";
CREATE UNIQUE INDEX "RepoSnapshot_repositoryId_key" ON "RepoSnapshot"("repositoryId");
ALTER TABLE "RepoSnapshot" ADD CONSTRAINT "RepoSnapshot_repositoryId_fkey"
FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ── 4. Herkunft uebernommener Aufgaben ──────────────────────
-- Bisher war die Issue-Nummer je Projekt eindeutig. Mit mehreren Repos
-- reicht das nicht mehr: Frontend und Backend haben beide ein Issue 5.
ALTER TABLE "Task" ADD COLUMN "sourceRepositoryId" TEXT;
UPDATE "Task" AS t
SET "sourceRepositoryId" = r."id"
FROM "Repository" AS r
WHERE r."projectId" = t."projectId" AND t."sourceIssue" IS NOT NULL;
DROP INDEX "Task_projectId_sourceIssue_key";
CREATE UNIQUE INDEX "Task_projectId_sourceRepositoryId_sourceIssue_key"
ON "Task"("projectId", "sourceRepositoryId", "sourceIssue");
-- ── 5. Projekt aufraeumen ───────────────────────────────────
ALTER TABLE "Project" DROP CONSTRAINT "Project_connectionId_fkey";
DROP INDEX "Project_connectionId_idx";
ALTER TABLE "Project"
DROP COLUMN "repoUrl",
DROP COLUMN "repoOwner",
DROP COLUMN "repoName",
DROP COLUMN "defaultBranch",
DROP COLUMN "connectionId",
DROP COLUMN "lastSyncedAt",
DROP COLUMN "syncError",
DROP COLUMN "autoSync",
DROP COLUMN "syncFailureCount",
DROP COLUMN "notifiedSyncError",
DROP COLUMN "notifiedCiState",
DROP COLUMN "notifiedRelease";
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
+286 -32
View File
@@ -33,6 +33,52 @@ enum GitProvider {
GITEA GITEA
} }
/// Zustellwege fuer Benachrichtigungen.
enum NotificationChannel {
WEBHOOK
NTFY
EMAIL
}
/// Anlaesse, ueber die benachrichtigt werden kann.
enum NotificationEvent {
/// Ein Abgleich mit dem Git-System ist fehlgeschlagen.
SYNC_FAILED
/// Der letzte CI-Lauf auf dem Default-Branch ist rot.
CI_FAILED
/// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht.
NEW_RELEASE
/// Seit laengerem kein Commit mehr – der sanfte Stups.
PROJECT_STALE
}
/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der
/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung
/// mitschreiben.
enum ActivityKind {
PROJECT_CREATED
PROJECT_UPDATED
STATUS_CHANGED
NOTE_ADDED
NOTE_UPDATED
NOTE_DELETED
TASK_ADDED
TASK_MOVED
TASK_DELETED
TASKS_IMPORTED
REPO_LINKED
REPO_COMMITS
SYNC_FAILED
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus {
TODO
DOING
BLOCKED
DONE
}
/// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat /// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat
/// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart. /// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart.
enum UserRole { enum UserRole {
@@ -66,10 +112,21 @@ model User {
failedLoginCount Int @default(0) failedLoginCount Int @default(0)
lockedUntil DateTime? lockedUntil DateTime?
/// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts).
/// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt
/// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst
/// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String?
totpEnabledAt DateTime?
sessions Session[] sessions Session[]
credentials Credential[] credentials Credential[]
projects Project[] projects Project[]
connections GitConnection[] connections GitConnection[]
notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -114,6 +171,45 @@ model Credential {
@@index([userId]) @@index([userId])
} }
/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist.
/// Jeder Code gilt genau einmal.
model RecoveryCode {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die
/// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos
/// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt
/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann
/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt.
model PendingLogin {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier.
tokenHash String @unique
/// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die
/// sechs Stellen nicht in Ruhe durchprobiert werden koennen.
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([expiresAt])
}
/// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie /// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie
/// kein zweites Mal verwendet werden kann. /// kein zweites Mal verwendet werden kann.
model WebAuthnChallenge { model WebAuthnChallenge {
@@ -155,6 +251,12 @@ model AppSettings {
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json? lastAutoSyncReport Json?
// ── Stillstand ─────────────────────────────────────────────
/// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt.
staleAfterDays Int @default(60)
/// Die Pruefung laeuft hoechstens einmal am Tag.
lastStaleCheckAt DateTime?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -184,7 +286,7 @@ model GitConnection {
ownerId String? ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
projects Project[] repositories Repository[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -213,41 +315,181 @@ model Project {
accent String @default("violet") accent String @default("violet")
tags String[] @default([]) tags String[] @default([])
favorite Boolean @default(false) favorite Boolean @default(false)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
// ── Git-Anbindung ────────────────────────────────────────── /// uebrigen Ansichten folgen der gewaehlten Sortierung.
repoUrl String? position Int @default(0)
/// 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 /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen. /// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String? ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
notes Note[] /// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
snapshot RepoSnapshot? /// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
/// Bleibt am Projekt: Stillstand ist eine Aussage ueber das Vorhaben, nicht
/// ueber ein einzelnes Repository.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
activities Activity[]
repositories Repository[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([ownerId, slug]) @@unique([ownerId, slug])
@@index([status]) @@index([status])
@@index([connectionId])
@@index([updatedAt]) @@index([updatedAt])
@@index([ownerId]) @@index([ownerId])
@@index([ownerId, status, position])
}
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
/// Frontend und Backend etwa –, gehoert aber in eine Karte.
///
/// Das erste Repository (position 0) ist das fuehrende: seine Kennzahlen
/// stehen auf der Projektkarte, wo fuer mehrere kein Platz ist.
model Repository {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Frei waehlbare Bezeichnung wie "Frontend". Leer heisst: der Repo-Name
/// steht fuer sich.
label String?
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)
/// Reihenfolge in der Anzeige.
position Int @default(0)
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)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
notifiedSyncError String?
notifiedCiState String?
notifiedRelease String?
snapshot RepoSnapshot?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([projectId, position])
@@index([connectionId])
}
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
model NotificationTarget {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
channel NotificationChannel
/// Webhook-URL, ntfy-Themen-URL oder E-Mail-Adresse.
target String
/// Optionales Geheimnis, AES-256-GCM verschluesselt: Bearer-Token fuer
/// ntfy oder ein Signaturschluessel fuer den Webhook.
secretCipher String?
secretHint String?
/// Worueber dieses Ziel benachrichtigt werden will.
events NotificationEvent[] @default([])
isActive Boolean @default(true)
lastSentAt DateTime?
lastOk Boolean?
lastMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts.
model Task {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
/// Optionale Notiz zur Aufgabe, Markdown wird dargestellt.
description String?
status TaskStatus @default(TODO)
/// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn
/// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt.
/// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert.
position Int @default(0)
dueDate DateTime?
labels String[] @default([])
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int?
sourceUrl String?
/// Aus welchem Repository. Muss mit in die Eindeutigkeit: Frontend und
/// Backend haben beide ein Issue Nummer 5, und das sind zwei Aufgaben.
sourceRepositoryId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
/// Verhindert Dubletten beim erneuten Uebernehmen. Mehrere Aufgaben ohne
/// Herkunft sind erlaubt: NULL gilt in Postgres als verschieden.
@@unique([projectId, sourceRepositoryId, sourceIssue])
@@index([projectId, status, position])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
/// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim
/// automatischen Abgleich.
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
kind ActivityKind
/// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben
/// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es
/// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz.
summary String
/// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht
/// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll.
meta Json?
createdAt DateTime @default(now())
/// Absteigend gelesen, immer je Projekt.
@@index([projectId, createdAt])
} }
/// Freie Notizen zu einem Projekt. /// Freie Notizen zu einem Projekt.
@@ -265,12 +507,12 @@ model Note {
@@index([pinned]) @@index([pinned])
} }
/// Zwischengespeicherter Stand des verknuepften Repositories. Wird beim /// Zwischengespeicherter Stand eines Repositories. Wird beim Sync
/// Sync ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt. /// ueberschrieben, damit das Dashboard auch ohne Netz Daten zeigt.
model RepoSnapshot { model RepoSnapshot {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String @unique repositoryId String @unique
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
fullName String? fullName String?
description String? description String?
@@ -298,11 +540,23 @@ model RepoSnapshot {
/// Rohdaten der Listen – bewusst als JSON, damit providerspezifische /// Rohdaten der Listen – bewusst als JSON, damit providerspezifische
/// Zusatzfelder nicht verloren gehen. /// Zusatzfelder nicht verloren gehen.
branches Json @default("[]") branches Json @default("[]")
tags Json @default("[]") tags Json @default("[]")
commits Json @default("[]") commits Json @default("[]")
releases Json @default("[]") releases Json @default("[]")
languages Json @default("{}") languages Json @default("{}")
pullRequests Json @default("[]")
issues Json @default("[]")
/// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]")
// ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled
ciState String?
ciName String?
ciBranch String?
ciUrl String?
ciFinishedAt DateTime?
pushedAt DateTime? pushedAt DateTime?
fetchedAt DateTime @default(now()) fetchedAt DateTime @default(now())
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+37 -1
View File
@@ -1,5 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { AccountManager, type AccountData } from "@/components/account/AccountManager"; import { AccountManager, type AccountData } from "@/components/account/AccountManager";
import {
NotificationSection,
type NotificationTargetEntry,
} from "@/components/account/NotificationSection";
import { emailAvailable } from "@/lib/notify";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { WEBAUTHN_AVAILABLE } from "@/lib/config"; import { WEBAUTHN_AVAILABLE } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -13,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AccountPage() { export default async function AccountPage() {
const user = await requireUserPage(); const user = await requireUserPage();
const [passkeys, sessions] = await Promise.all([ const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
prisma.credential.findMany({ prisma.credential.findMany({
where: { userId: user.id }, where: { userId: user.id },
select: { select: {
@@ -37,6 +42,27 @@ export default async function AccountPage() {
}, },
orderBy: { lastSeenAt: "desc" }, orderBy: { lastSeenAt: "desc" },
}), }),
prisma.notificationTarget.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
channel: true,
target: true,
secretHint: true,
events: true,
isActive: true,
lastSentAt: true,
lastOk: true,
lastMessage: true,
},
orderBy: { name: "asc" },
}),
prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: { totpEnabledAt: true },
}),
prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }),
]); ]);
const data: AccountData = { const data: AccountData = {
@@ -48,6 +74,11 @@ export default async function AccountPage() {
passkeys, passkeys,
sessions, sessions,
passkeysAvailable: WEBAUTHN_AVAILABLE, passkeysAvailable: WEBAUTHN_AVAILABLE,
totp: {
enabled: Boolean(konto.totpEnabledAt),
enabledAt: konto.totpEnabledAt,
recoveryCodesLeft: offeneCodes,
},
}; };
return ( return (
@@ -60,6 +91,11 @@ export default async function AccountPage() {
</header> </header>
<AccountManager data={data} /> <AccountManager data={data} />
<NotificationSection
targets={targets as unknown as NotificationTargetEntry[]}
emailAvailable={emailAvailable()}
/>
</div> </div>
); );
} }
+12 -2
View File
@@ -19,7 +19,7 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() { export default async function AdminPage() {
const admin = await requireAdminPage(); const admin = await requireAdminPage();
const [settings, users, eligible] = await Promise.all([ const [settings, users, eligible, staleListeners] = await Promise.all([
getAppSettings(), getAppSettings(),
prisma.user.findMany({ prisma.user.findMany({
select: userSafeSelect, select: userSafeSelect,
@@ -27,7 +27,9 @@ export default async function AdminPage() {
}), }),
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine // Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen. // Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
prisma.project.count({ // Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
// der abgeglichen und begrenzt wird.
prisma.repository.count({
where: { where: {
autoSync: true, autoSync: true,
OR: [ OR: [
@@ -36,6 +38,9 @@ export default async function AdminPage() {
], ],
}, },
}), }),
prisma.notificationTarget.count({
where: { isActive: true, events: { has: "PROJECT_STALE" } },
}),
]); ]);
const data: AdminData = { const data: AdminData = {
@@ -54,6 +59,11 @@ export default async function AdminPage() {
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null, lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
eligible, eligible,
}, },
stale: {
afterDays: settings.staleAfterDays,
lastCheckAt: settings.lastStaleCheckAt,
listeners: staleListeners,
},
}; };
return ( return (
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { issueRecoveryCodes } from "@/lib/auth/recovery";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
export const dynamic = "force-dynamic";
const schema = z.object({ password: z.string().min(1).max(200) });
/**
* Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren
* Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten.
*
* Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig
* zum zweiten Faktor.
*/
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 = schema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) {
return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
}
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) });
}
+167
View File
@@ -0,0 +1,167 @@
import { NextResponse } from "next/server";
import QRCode from "qrcode";
import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Zwei-Faktor-Anmeldung per Einmalkennwort.
*
* Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und
* zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf.
* Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den
* QR-Code nie scannt, sperrt sich so nicht aus.
*/
/** Zustand fuer die Anzeige. */
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true },
});
return NextResponse.json({
enabled: Boolean(user.totpEnabledAt),
enabledAt: user.totpEnabledAt,
recoveryCodesLeft: user.totpEnabledAt
? await countUnusedRecoveryCodes(auth.user.id)
: 0,
});
}
/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */
export async function POST() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpEnabledAt: true, username: true },
});
if (user.totpEnabledAt) {
return jsonError(
"Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.",
409,
);
}
// Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus
// einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne
// dass hier bekannt waere, in welcher.
const secret = generateTotpSecret();
await prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: encryptSecret(secret) },
});
const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME });
return NextResponse.json({
// Das Bild als data:-URI, damit die Content-Security-Policy nichts
// nachladen muss. img-src erlaubt data: bereits.
qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }),
uri,
/** Zum Abtippen, falls die Kamera nicht mitspielt. */
secret: groupSecret(secret),
});
}
const enableSchema = z.object({ code: z.string().min(6).max(10) });
/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */
export async function PUT(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 = enableSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { totpSecretCipher: true, totpEnabledAt: true },
});
if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409);
const secret = tryDecryptSecret(user.totpSecretCipher);
if (!secret) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
}
if (!verifyTotp(secret, parsed.data.code)) {
return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401,
);
}
await prisma.user.update({
where: { id: auth.user.id },
data: { totpEnabledAt: new Date() },
});
return NextResponse.json({
ok: true,
// Einmalige Ausgabe: gespeichert wird nur ihr Hash.
recoveryCodes: await issueRecoveryCodes(auth.user.id),
});
}
const disableSchema = z.object({ password: z.string().min(1).max(200) });
/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */
export async function DELETE(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 = disableSchema.safeParse(body.data);
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
const user = await prisma.user.findUniqueOrThrow({
where: { id: auth.user.id },
select: { passwordHash: true, totpEnabledAt: true },
});
if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
// Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor
// nicht im Vorbeigehen entfernen koennen.
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
return jsonError("Das Passwort stimmt nicht.", 401);
}
await prisma.$transaction([
prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null },
}),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
]);
return NextResponse.json({ ok: true });
}
+1
View File
@@ -56,6 +56,7 @@ export async function GET() {
prisma.project.findMany({ prisma.project.findMany({
include: { include: {
notes: { orderBy: { createdAt: "asc" } }, notes: { orderBy: { createdAt: "asc" } },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
// Snapshots bleiben draussen: reproduzierbar und gross. // Snapshots bleiben draussen: reproduzierbar und gross.
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
+6
View File
@@ -37,6 +37,7 @@ export async function PATCH(request: Request) {
autoSyncIntervalMinutes, autoSyncIntervalMinutes,
autoSyncActiveOnly, autoSyncActiveOnly,
autoSyncBatchSize, autoSyncBatchSize,
staleAfterDays,
} = parsed.data; } = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
@@ -57,6 +58,11 @@ export async function PATCH(request: Request) {
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}), ...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}), ...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}), ...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
// Neue Schwelle: die Pruefung soll beim naechsten Tick greifen, nicht
// erst in 24 Stunden.
...(staleAfterDays !== undefined
? { staleAfterDays, lastStaleCheckAt: null }
: {}),
}; };
const settings = await prisma.appSettings.upsert({ const settings = await prisma.appSettings.upsert({
+13
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session"; import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users"; import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
@@ -68,6 +69,18 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401); return jsonError(GENERIC, 401);
} }
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
// noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist.
if (user.totpEnabledAt) {
// Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist:
// sonst liesse sich ueber wiederholte Passworteingaben die Sperre
// umgehen, die falsche Passwoerter aufbaut.
const token = await createPendingLogin(user.id);
const response = NextResponse.json({ ok: false, mfaRequired: true });
setMfaCookie(response, token);
return response;
}
await noteSuccessfulLogin(user.id); await noteSuccessfulLogin(user.id);
resetRateLimit(`login:user:${username}`); resetRateLimit(`login:user:${username}`);
resetRateLimit(`login:ip:${ip}`); resetRateLimit(`login:ip:${ip}`);
+98
View File
@@ -0,0 +1,98 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import {
clearMfaCookie,
discardPendingLogin,
noteMfaFailure,
readPendingLogin,
} from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
export const dynamic = "force-dynamic";
/**
* Der zweite Schritt der Anmeldung: Einmalkennwort oder
* Wiederherstellungscode.
*
* Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne
* diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts.
*/
const schema = z.object({
code: z.string().min(1).max(40),
});
const GENERIC = "Der Code stimmt nicht.";
export async function POST(request: Request) {
const ip = clientIp(request);
const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data);
if (!parsed.success) return jsonError(GENERIC, 401);
const pending = await readPendingLogin();
if (!pending) {
const response = jsonError(
"Die Anmeldung ist abgelaufen. Bitte melde dich neu an.",
401,
);
clearMfaCookie(response);
return response;
}
if (!pending.user.isActive) {
await discardPendingLogin(pending.id);
const response = jsonError("Dieses Konto ist deaktiviert.", 403);
clearMfaCookie(response);
return response;
}
const eingabe = parsed.data.code.trim();
// Sechs Ziffern sind ein Einmalkennwort, alles andere ein
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe)
: await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id);
if (!weiterMoeglich) {
const response = jsonError(
"Zu viele Fehlversuche. Bitte melde dich neu an.",
429,
);
clearMfaCookie(response);
return response;
}
return jsonError(GENERIC, 401);
}
await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id);
const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"),
ip,
});
const response = NextResponse.json({
ok: true,
user: { id: pending.user.id, username: pending.user.username, role: pending.user.role },
});
setSessionCookie(response, session.token, session.expiresAt);
clearMfaCookie(response);
return response;
}
+1 -1
View File
@@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string }> };
const safeSelect = { const safeSelect = {
...connectionSafeSelect, ...connectionSafeSelect,
_count: { select: { projects: true } }, _count: { select: { repositories: true } },
}; };
export async function PATCH(request: Request, { params }: Params) { export async function PATCH(request: Request, { params }: Params) {
+1 -1
View File
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */ /** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
const safeSelect = { const safeSelect = {
...connectionSafeSelect, ...connectionSafeSelect,
_count: { select: { projects: true } }, _count: { select: { repositories: true } },
}; };
export async function GET() { export async function GET() {
+28
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -44,6 +45,18 @@ export async function PATCH(request: Request, { params }: Params) {
}, },
}); });
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
await recordActivity({
projectId: note.projectId,
userId: auth.user.id,
kind: "NOTE_UPDATED",
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
meta: { noteId: id },
});
}
return NextResponse.json({ note: updated }); return NextResponse.json({ note: updated });
} }
@@ -53,10 +66,25 @@ export async function DELETE(_request: Request, { params }: Params) {
const { id } = await params; const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await prisma.note.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: { projectId: true, title: true, content: true },
});
const result = await prisma.note.deleteMany({ const result = await prisma.note.deleteMany({
where: { id, project: { ownerId: auth.user.id } }, where: { id, project: { ownerId: auth.user.id } },
}); });
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404); if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
if (vorher) {
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "NOTE_DELETED",
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
});
}
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { targetSafeSelect } from "@/lib/notify/types";
import { formatZodError, notificationTargetUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
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.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!existing) return jsonError("Ziel nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = notificationTargetUpdateSchema.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.events !== undefined) update.events = data.events;
if (data.isActive !== undefined) update.isActive = data.isActive;
if (data.target !== undefined) {
if (existing.channel !== "EMAIL") {
try {
await assertAllowedUrl(data.target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
update.target = data.target;
}
// Leeres Feld heisst "unveraendert", null heisst "entfernen".
if (data.secret !== undefined) {
if (data.secret) {
update.secretCipher = encryptSecret(data.secret);
update.secretHint = tokenHint(data.secret);
} else {
update.secretCipher = null;
update.secretHint = null;
}
}
try {
const target = await prisma.notificationTarget.update({
where: { id },
data: update,
select: targetSafeSelect,
});
return NextResponse.json({ target });
} catch {
return jsonError("Name bereits vergeben.", 409);
}
}
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.notificationTarget.deleteMany({
where: { id, userId: auth.user.id },
});
if (result.count === 0) return jsonError("Ziel nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { deliver } from "@/lib/notify/channels";
import { testMessage } from "@/lib/notify";
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 }> };
/** Schickt eine Testnachricht an genau dieses Ziel. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Loest einen ausgehenden Aufruf aus – entsprechend begrenzt.
const limited = rateLimit(
`notify-test:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
const target = await prisma.notificationTarget.findFirst({
where: { id, userId: auth.user.id },
});
if (!target) return jsonError("Ziel nicht gefunden", 404);
const result = await deliver(target, testMessage());
await prisma.notificationTarget.update({
where: { id },
data: { lastSentAt: new Date(), lastOk: result.ok, lastMessage: result.message },
});
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
}
+83
View File
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { encryptSecret, tokenHint } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { targetSafeSelect } from "@/lib/notify/types";
import { BlockedHostError, assertAllowedUrl } from "@/lib/security/ssrf";
import { formatZodError, notificationTargetCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const targets = await prisma.notificationTarget.findMany({
where: { userId: auth.user.id },
select: targetSafeSelect,
orderBy: { name: "asc" },
});
return NextResponse.json({ targets });
}
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 = notificationTargetCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { name, channel, target, secret, events, isActive } = parsed.data;
// Webhook und ntfy ruft der Server spaeter selbst auf – gleiche Pruefung
// wie bei den Git-Verbindungen.
if (channel !== "EMAIL") {
try {
await assertAllowedUrl(target);
} catch (error) {
if (error instanceof BlockedHostError) {
return NextResponse.json(
{ error: error.message, fields: { target: error.message } },
{ status: 422 },
);
}
throw error;
}
}
const duplicate = await prisma.notificationTarget.findFirst({
where: { userId: auth.user.id, name },
select: { id: true },
});
if (duplicate) {
return NextResponse.json(
{ error: "Name bereits vergeben.", fields: { name: "Name bereits vergeben" } },
{ status: 409 },
);
}
const created = await prisma.notificationTarget.create({
data: {
userId: auth.user.id,
name,
channel,
target,
secretCipher: secret ? encryptSecret(secret) : null,
secretHint: secret ? tokenHint(secret) : null,
events,
isActive,
},
select: targetSafeSelect,
});
return NextResponse.json({ target: created }, { status: 201 });
}
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { ACTIVITY_LIMIT } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
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);
}
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
const roh = new URL(request.url).searchParams.get("limit");
const angefragt = roh === null ? Number.NaN : Number(roh);
const take = Number.isFinite(angefragt)
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
: 30;
const activities = await prisma.activity.findMany({
where: { projectId: id },
orderBy: { createdAt: "desc" },
take,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
});
return NextResponse.json({ activities });
}
+11 -4
View File
@@ -1,7 +1,7 @@
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format"; import { exportFileName } from "@/lib/export/format";
import { buildExportFile, toMarkdown } from "@/lib/export/project"; import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request"; import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -24,9 +24,16 @@ export async function GET(request: Request, { params }: Params) {
const project = await prisma.project.findFirst({ const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id }, where: { id, ownerId: auth.user.id },
include: { include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, ...exportInclude,
connection: { select: { name: true, provider: true, baseUrl: true } }, // Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
snapshot: true, // die Datei auch ohne Netz vollstaendig ist.
repositories: {
include: {
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
}, },
}); });
+9
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -64,5 +65,13 @@ export async function POST(request: Request, { params }: Params) {
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt. // Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } }); await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "NOTE_ADDED",
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
meta: { noteId: note.id },
});
return NextResponse.json({ note }, { status: 201 }); return NextResponse.json({ note }, { status: 201 });
} }
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import {
completeRepoInput,
namesRepository,
nextRepoPosition,
repoTitle,
} from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
const MAX_REPOS = 20;
const include = {
connection: { select: connectionSafeSelect },
snapshot: true,
} as const;
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 repositories = await prisma.repository.findMany({
where: { projectId: id },
include,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ repositories });
}
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 = repositoryCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (!namesRepository(eingabe)) {
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
}
// Eine fremde Verbindung darf nicht untergeschoben werden.
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
if (vorhanden >= MAX_REPOS) {
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
}
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
if (eingabe.repoOwner && eingabe.repoName) {
const doppelt = await prisma.repository.findFirst({
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
select: { id: true },
});
if (doppelt) {
return jsonError(
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
409,
);
}
}
const repository = await prisma.repository.create({
data: {
projectId: id,
label: eingabe.label ?? null,
repoUrl: eingabe.repoUrl ?? null,
repoOwner: eingabe.repoOwner ?? null,
repoName: eingabe.repoName ?? null,
connectionId: eingabe.connectionId ?? null,
autoSync: eingabe.autoSync ?? true,
position: await nextRepoPosition(id),
},
include,
});
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
meta: { repositoryId: repository.id },
});
return NextResponse.json({ repository }, { status: 201 });
}
+95 -18
View File
@@ -1,9 +1,17 @@
import type { ProjectStatus } from "@prisma/client";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git"; import {
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation"; import { formatZodError, projectUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -74,35 +82,104 @@ export async function PATCH(request: Request, { params }: Params) {
"accent", "accent",
"tags", "tags",
"favorite", "favorite",
"autoSync", "progressFromTasks",
"connectionId",
] as const) { ] as const) {
if (data[key] !== undefined) update[key] = data[key]; if (data[key] !== undefined) update[key] = data[key];
} }
if (data.repoUrl !== undefined) { // Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
update.repoUrl = data.repoUrl; // seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null; // angekommen ist.
if (parsedUrl) { if (data.status !== undefined && data.status !== existing.status) {
update.repoOwner = data.repoOwner ?? parsedUrl.owner; update.position = await nextProjectPosition(auth.user.id, data.status);
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({ await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
const repoFelder = {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
};
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) {
await refreshDerivedProgress(id);
}
const project = await prisma.project.findUniqueOrThrow({
where: { id }, where: { id },
data: update,
include: projectListInclude, include: projectListInclude,
}); });
return NextResponse.json({ project }); return NextResponse.json({ project });
} }
/**
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
* Farbe den Verlauf.
*/
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus },
update: Record<string, unknown>,
repo: RepoInput | null,
) {
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
const entries: Parameters<typeof recordActivities>[0] = [];
if (update.status !== undefined && update.status !== before.status) {
const to = update.status as ProjectStatus;
entries.push({
projectId,
userId,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, to),
meta: { from: before.status, to },
});
}
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
entries.push({
projectId,
userId,
kind: "REPO_LINKED",
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
meta: { repoUrl: repo.repoUrl ?? null },
});
}
if (repo?.autoSync !== undefined) rest.push("autoSync");
if (repo?.connectionId !== undefined) rest.push("connectionId");
const felder = describeFields(rest);
if (felder) {
entries.push({
projectId,
userId,
kind: "PROJECT_UPDATED",
summary: felder,
meta: { fields: rest },
});
}
await recordActivities(entries);
}
export async function DELETE(_request: Request, { params }: Params) { export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi(); const auth = await requireUserApi();
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
@@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { repoTitle } from "@/lib/repositories";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { asArray, type TicketInfo } from "@/types";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
numbers: z.array(z.number().int()).max(200).optional(),
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
* Uebernimmt offene Issues des verknuepften Repositories als Aufgaben.
*
* Grundlage ist der zuletzt geholte Snapshot – hier wird nichts nachgeladen,
* damit ein Klick nicht ungefragt fremde APIs anspricht. Wer den neuesten
* Stand will, gleicht vorher ab.
*
* Bereits uebernommene Issues werden uebersprungen: die Nummer steht an der
* Aufgabe und ist je Projekt eindeutig. Ein zweiter Aufruf legt also keine
* Dubletten an, auch wenn die Aufgabe inzwischen verschoben oder umbenannt
* wurde.
*/
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 = schema.safeParse(body.data ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
const repository = await prisma.repository.findFirst({
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { snapshot: true },
});
if (!repository) {
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
return parsed.data.repositoryId
? jsonError("Repository nicht gefunden", 404)
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
}
const snapshot = repository.snapshot;
if (!snapshot) {
return jsonError(
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
409,
);
}
const alle = asArray<TicketInfo>(snapshot.issues);
const gewuenscht = parsed.data.numbers;
const auswahl = gewuenscht?.length
? alle.filter((issue) => gewuenscht.includes(issue.number))
: alle;
if (auswahl.length === 0) {
return NextResponse.json({ created: 0, skipped: 0, tasks: [] });
}
const vorhanden = await prisma.task.findMany({
where: {
projectId: id,
sourceRepositoryId: repository.id,
sourceIssue: { in: auswahl.map((issue) => issue.number) },
},
select: { sourceIssue: true },
});
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
const neu = auswahl.filter((issue) => !bekannt.has(issue.number));
let position = await nextPosition(id, "TODO");
const angelegt = [];
for (const issue of neu) {
angelegt.push(
await prisma.task.create({
data: {
projectId: id,
title: issue.title.slice(0, 200),
// Der Link ist das Wichtigste – der Text steht im Git-System.
description: issue.url ? `[Issue #${issue.number}](${issue.url})` : null,
status: "TODO",
position: position++,
labels: issue.labels.slice(0, 8),
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
}),
);
}
if (angelegt.length > 0) {
await refreshDerivedProgress(id);
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASKS_IMPORTED",
summary:
angelegt.length === 1
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
meta: {
repositoryId: repository.id,
numbers: angelegt.map((task) => task.sourceIssue),
},
});
}
return NextResponse.json({
created: angelegt.length,
skipped: auswahl.length - angelegt.length,
tasks: angelegt,
});
}
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Neue Reihenfolge nach dem Ziehen.
*
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
* und ein verlorener Aufruf laesst sich einfach erneut senden.
*/
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
const owned = await prisma.task.findMany({
where: { projectId: id, id: { in: requestedIds } },
select: { id: true, status: true },
});
const ownedIds = new Set(owned.map((task) => task.id));
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
data: {
status,
position: index,
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
// immer wieder erneuert.
...(before !== status
? { doneAt: status === "DONE" ? now : null }
: {}),
},
}),
);
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await refreshDerivedProgress(id);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({
tasks,
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+78
View File
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({ tasks });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const task = await prisma.task.create({
data: {
projectId: id,
title,
description: description ?? null,
status,
// Neue Aufgaben landen unten in ihrer Spalte.
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
doneAt: status === "DONE" ? new Date() : null,
},
});
await refreshDerivedProgress(id);
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 });
}
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
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 = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+72 -17
View File
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format"; import {
import { uniqueSlug } from "@/lib/projects"; exportFileSchema,
repositoriesOf,
type ExportedRepository,
} from "@/lib/export/format";
import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { import {
IMPORT_MAX_BODY_BYTES, IMPORT_MAX_BODY_BYTES,
@@ -84,7 +89,7 @@ export async function POST(request: Request) {
select: { id: true, name: true, provider: true, baseUrl: true }, select: { id: true, name: true, provider: true, baseUrl: true },
}); });
function matchConnection(described: ExportedProject["connection"]) { function matchConnection(described: ExportedRepository["connection"]) {
if (!described) return null; if (!described) return null;
const byName = connections.find( const byName = connections.find(
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(), (candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
@@ -104,12 +109,26 @@ export async function POST(request: Request) {
for (const incoming of file.projects) { for (const incoming of file.projects) {
const slug = incoming.slug || slugify(incoming.name) || "projekt"; const slug = incoming.slug || slugify(incoming.name) || "projekt";
const connection = matchConnection(incoming.connection);
const connectionState: ItemResult["connection"] = incoming.connection // Version 1 kannte ein Repository je Projekt und schrieb es flach; seit
? connection // Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied.
? "matched" const eingehendeRepos = repositoriesOf(incoming);
: "missing" const repos = eingehendeRepos.map((repo, index) => ({
: "none"; label: repo.label ?? null,
repoUrl: repo.repoUrl ?? null,
repoOwner: repo.repoOwner ?? null,
repoName: repo.repoName ?? null,
autoSync: repo.autoSync ?? true,
connectionId: matchConnection(repo.connection ?? null)?.id ?? null,
position: index,
}));
// Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene
// Verbindung fehlt – welche, steht in der Datei.
const beschrieben = eingehendeRepos.filter((repo) => repo.connection);
const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null));
const connectionState: ItemResult["connection"] =
beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched";
const existing = await prisma.project.findFirst({ const existing = await prisma.project.findFirst({
where: { slug, ownerId: auth.user.id }, where: { slug, ownerId: auth.user.id },
@@ -122,7 +141,7 @@ export async function POST(request: Request) {
action: "create", action: "create",
notes: incoming.notes.length, notes: incoming.notes.length,
connection: connectionState, connection: connectionState,
connectionName: incoming.connection?.name, connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name,
}; };
if (existing && strategy === "skip") { if (existing && strategy === "skip") {
@@ -145,6 +164,8 @@ export async function POST(request: Request) {
} }
const data = { const data = {
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
position: await nextProjectPosition(auth.user.id, incoming.status),
name: incoming.name, name: incoming.name,
summary: incoming.summary ?? null, summary: incoming.summary ?? null,
description: incoming.description ?? null, description: incoming.description ?? null,
@@ -154,13 +175,25 @@ export async function POST(request: Request) {
accent: incoming.accent, accent: incoming.accent,
tags: incoming.tags, tags: incoming.tags,
favorite: incoming.favorite, favorite: incoming.favorite,
autoSync: incoming.autoSync, progressFromTasks: incoming.progressFromTasks,
repoUrl: incoming.repoUrl ?? null,
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null,
}; };
const tasks = incoming.tasks.map((task, index) => ({
title: task.title,
description: task.description ?? null,
status: task.status,
// Position aus der Datei uebernehmen, notfalls die Reihenfolge in ihr.
position: task.position ?? index,
dueDate: task.dueDate && !Number.isNaN(Date.parse(task.dueDate))
? new Date(task.dueDate)
: null,
labels: task.labels,
doneAt: task.status === "DONE" ? new Date() : null,
...(task.createdAt && !Number.isNaN(Date.parse(task.createdAt))
? { createdAt: new Date(task.createdAt) }
: {}),
}));
const notes = incoming.notes.map((note) => ({ const notes = incoming.notes.map((note) => ({
title: note.title ?? null, title: note.title ?? null,
content: note.content, content: note.content,
@@ -176,22 +209,44 @@ export async function POST(request: Request) {
// erneuter Import erzeugt keine Dubletten. // erneuter Import erzeugt keine Dubletten.
await prisma.$transaction([ await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }), prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
// Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der
// gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten
// Abgleich zurueck.
prisma.repository.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({ prisma.project.update({
where: { id: existing.id }, where: { id: existing.id },
data: { ...data, slug, notes: { create: notes } }, data: {
...data,
slug,
notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
},
}), }),
]); ]);
results.push({ ...base, action: "overwrite" }); results.push({ ...base, action: "overwrite" });
} else { } else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug; const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({ const angelegt = await prisma.project.create({
data: { data: {
...data, ...data,
ownerId: auth.user.id, ownerId: auth.user.id,
slug: finalSlug, slug: finalSlug,
notes: { create: notes }, notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
}, },
}); });
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
// wenigstens fest, woher das Projekt stammt.
await recordActivity({
projectId: angelegt.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary: "Aus einer Exportdatei importiert",
meta: { notes: notes.length, tasks: tasks.length },
});
results.push({ results.push({
...base, ...base,
action: existing ? "rename" : "create", action: existing ? "rename" : "create",
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Neue Reihenfolge im Brett nach dem Ziehen.
*
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
* anzurichten.
*/
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 = projectReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
// untergeschobene IDs verschieben.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: requestedIds } },
select: { id: true, status: true, updatedAt: true },
});
const ownedIds = new Set(owned.map((project) => project.id));
const vorher = new Map(owned.map((project) => [project.id, project]));
const updates: Prisma.PrismaPromise<unknown>[] = [];
const entries: Parameters<typeof recordActivities>[0] = [];
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
ids.forEach((projectId, index) => {
const before = vorher.get(projectId);
if (!ownedIds.has(projectId) || !before) return;
const gewandert = before.status !== status;
updates.push(
prisma.project.update({
where: { id: projectId },
data: {
status,
position: index,
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
...(gewandert ? {} : { updatedAt: before.updatedAt }),
},
}),
);
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
if (gewandert) {
entries.push({
projectId,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, status),
meta: { from: before.status, to: status },
});
}
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+32 -19
View File
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git"; import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
import { projectListInclude, uniqueSlug } from "@/lib/projects"; import { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { statusOf } from "@/lib/status";
import { formatZodError, projectCreateSchema } from "@/lib/validation"; import { formatZodError, projectCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -72,18 +74,7 @@ export async function POST(request: Request) {
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
} }
// Owner/Name aus der URL ableiten, falls nicht explizit angegeben. const angelegt = await prisma.project.create({
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: { data: {
ownerId: auth.user.id, ownerId: auth.user.id,
name: data.name, name: data.name,
@@ -96,14 +87,36 @@ export async function POST(request: Request) {
accent: data.accent, accent: data.accent,
tags: data.tags, tags: data.tags,
favorite: data.favorite, favorite: data.favorite,
autoSync: data.autoSync, // Hinten an die Statusspalte des Bretts.
repoUrl: data.repoUrl ?? null, position: await nextProjectPosition(auth.user.id, data.status),
repoOwner,
repoName,
connectionId: data.connectionId ?? null,
}, },
select: { id: true },
});
// Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen.
await applyLeadingRepo(angelegt.id, {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
});
const project = await prisma.project.findUniqueOrThrow({
where: { id: angelegt.id },
include: projectListInclude, include: projectListInclude,
}); });
await recordActivity({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary:
data.status === "IDEA"
? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status },
});
return NextResponse.json({ project }, { status: 201 }); return NextResponse.json({ project }, { status: 201 });
} }
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { completeRepoInput, ownedRepository, repoTitle } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, repositoryUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
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 ownedRepository(id, auth.user.id);
if (!existing) return jsonError("Repository nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = repositoryUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const data: Record<string, unknown> = {};
for (const key of ["label", "repoUrl", "repoOwner", "repoName", "connectionId", "autoSync"] as const) {
if (eingabe[key] !== undefined) data[key] = eingabe[key];
}
if (parsed.data.position !== undefined) data.position = parsed.data.position;
// Zeigt das Repo woandershin, taugt der gesammelte Stand nicht mehr. Ihn
// stehen zu lassen waere schlimmer als ihn zu verlieren: er saehe echt aus.
const zieltWoandershin =
(eingabe.repoOwner !== undefined && eingabe.repoOwner !== existing.repoOwner) ||
(eingabe.repoName !== undefined && eingabe.repoName !== existing.repoName);
if (zieltWoandershin) {
data.lastSyncedAt = null;
data.syncError = null;
data.syncFailureCount = 0;
data.notifiedSyncError = null;
data.notifiedCiState = null;
data.notifiedRelease = null;
await prisma.repoSnapshot.deleteMany({ where: { repositoryId: id } });
}
const repository = await prisma.repository.update({
where: { id },
data,
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ repository });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await ownedRepository(id, auth.user.id);
if (!vorher) return jsonError("Repository nicht gefunden", 404);
await prisma.repository.delete({ where: { id } });
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository entfernt: ${shorten(repoTitle(vorher))}`,
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { ownedRepository } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { syncRepository } from "@/lib/sync";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Gleicht ein einzelnes Repository 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;
if (!(await ownedRepository(id, auth.user.id))) {
return jsonError("Repository nicht gefunden", 404);
}
const result = await syncRepository(id);
const repository = await prisma.repository.findUnique({
where: { id },
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ ...result, repository }, { status: result.ok ? 200 : 502 });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
interface Hit {
projectId: string;
noteId: string;
title: string | null;
/** Textausschnitt mit hervorgehobener Fundstelle. */
snippet: string;
}
/**
* Volltextsuche ueber Notizen.
*
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt:
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank.
*
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) {
return NextResponse.json({ query, hits: [], projectIds: [] });
}
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
// Anfuehrungszeichen und wirft bei Unsinn keinen Fehler.
const hits = await prisma.$queryRaw<Hit[]>`
SELECT
n."projectId" AS "projectId",
n."id" AS "noteId",
n."title" AS "title",
ts_headline(
'german',
n."content",
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Note" n
JOIN "Project" p ON p."id" = n."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(n."title", '') || ' ' || n."content")
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(n."title", '') || ' ' || n."content"),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({
query,
hits,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden.
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))),
});
}
+98
View File
@@ -0,0 +1,98 @@
import type { TaskStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Eine Aufgabe gehoert dem, dem das zugehoerige Projekt gehoert. */
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true, title: true },
});
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
const update: Record<string, unknown> = {};
if (data.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels;
if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
}
if (data.status !== undefined && data.status !== existing.status) {
update.status = data.status;
// Beim Spaltenwechsel ans Ende der neuen Spalte.
update.position = await nextPosition(existing.projectId, data.status);
// doneAt nur setzen, wenn die Aufgabe wirklich nach DONE wandert.
update.doneAt = data.status === "DONE" ? new Date() : null;
}
const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId);
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
if (update.status !== undefined) {
const nach = update.status as TaskStatus;
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach },
});
}
return NextResponse.json({ task });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await ownedTask(id, auth.user.id);
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId);
await recordActivity({
projectId: existing.projectId,
userId: auth.user.id,
kind: "TASK_DELETED",
summary: `Aufgabe gelöscht: ${shorten(existing.title, 50)}`,
});
return NextResponse.json({ ok: true });
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function ConnectionsPage() {
const connections = await prisma.gitConnection.findMany({ const connections = await prisma.gitConnection.findMany({
where: { ownerId: user.id }, where: { ownerId: user.id },
select: { ...connectionSafeSelect, _count: { select: { projects: true } } }, select: { ...connectionSafeSelect, _count: { select: { repositories: true } } },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
+21 -2
View File
@@ -1,7 +1,9 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { ChangelogButton } from "@/components/ChangelogButton";
import { CommandPalette } from "@/components/CommandPalette";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { APP_NAME, APP_TAGLINE } from "@/lib/config"; import { APP_NAME, APP_TAGLINE, APP_VERSION } from "@/lib/config";
import "./globals.css"; import "./globals.css";
/** /**
@@ -19,6 +21,20 @@ export async function generateMetadata(): Promise<Metadata> {
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.", "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME, applicationName: APP_NAME,
robots: { index: false, follow: false }, robots: { index: false, follow: false },
manifest: "/manifest.webmanifest",
// iOS liest das Manifest nicht; dort braucht es diese Angaben einzeln.
appleWebApp: { capable: true, title: APP_NAME, statusBarStyle: "black-translucent" },
// Beide Eintraege ausdruecklich: sobald hier etwas steht, ersetzt es den
// Verweis, den Next aus app/icon.svg von selbst erzeugt. Ohne die erste
// Zeile haette die Seite gar kein Symbol mehr und der Browser fiele auf
// /favicon.ico zurueck – das es hier nicht gibt.
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
}; };
} }
@@ -45,6 +61,9 @@ export default async function RootLayout({
<TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} /> <TopNav user={user} appName={APP_NAME} tagline={APP_TAGLINE} />
{/* Nur fuer Angemeldete – auf der Anmeldeseite gibt es nichts zu suchen. */}
{user ? <CommandPalette isAdmin={user.role === "ADMIN"} /> : null}
<main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8"> <main className="mx-auto w-full max-w-[1400px] px-4 pb-24 pt-6 sm:px-6 lg:px-8">
{children} {children}
</main> </main>
@@ -52,7 +71,7 @@ export default async function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8"> <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"> <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>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v0.2.0</span> <ChangelogButton version={APP_VERSION} />
</div> </div>
</footer> </footer>
</body> </body>
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
/**
* Web-App-Manifest: macht Nebula auf dem Handy installierbar.
*
* Als Funktion und nicht als Konstante, aus demselben Grund wie bei
* generateMetadata: 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 const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${APP_NAME} – ${APP_TAGLINE} Dashboard`,
short_name: APP_NAME,
description:
"Uebersicht ueber alle Vibe-Coding-Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
// Ohne Adressleiste, aber mit den Systemleisten – die Anwendung braucht
// keinen Vollbildmodus, wohl aber die Uhrzeit und den Akkustand.
display: "standalone",
start_url: "/",
scope: "/",
background_color: "#05060f",
theme_color: "#05060f",
lang: "de",
orientation: "any",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Getrennte Fassung mit mehr Rand: Android schneidet maskierbare
// Symbole je nach Geraet rund, eckig oder als Tropfen zu.
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
shortcuts: [
{ name: "Dashboard", url: "/" },
{ name: "Mein Konto", url: "/account" },
],
};
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function DashboardPage() {
const ideas = active.filter((project) => project.status === "IDEA").length; const ideas = active.filter((project) => project.status === "IDEA").length;
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length; const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
const done = active.filter((project) => project.status === "DONE").length; const done = active.filter((project) => project.status === "DONE").length;
const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length; const linked = active.filter((project) => project.repositories.length > 0).length;
const averageProgress = const averageProgress =
active.length > 0 active.length > 0
+14 -2
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel"; import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -59,6 +61,12 @@ export default async function ProjectPage({ params }: PageProps) {
connections={connections as unknown as ConnectionSafe[]} connections={connections as unknown as ConnectionSafe[]}
/> />
<TaskBoard
projectId={detail.id}
tasks={detail.tasks}
progressFromTasks={detail.progressFromTasks}
/>
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5"> <div className="space-y-6 lg:col-span-5">
<section className="glass p-5"> <section className="glass p-5">
@@ -78,8 +86,12 @@ export default async function ProjectPage({ params }: PageProps) {
<NotesPanel projectId={detail.id} notes={detail.notes} /> <NotesPanel projectId={detail.id} notes={detail.notes} />
</div> </div>
<div className="lg:col-span-7"> <div className="space-y-6 lg:col-span-7">
<GitPanel project={detail} /> <GitPanel
project={detail}
connections={connections as unknown as ConnectionSafe[]}
/>
<ActivityFeed projectId={detail.id} initial={detail.activities} />
</div> </div>
</div> </div>
</div> </div>
+148
View File
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import {
AlertTriangle,
ArrowRight,
Bot,
Download,
GitCommitHorizontal,
History,
Link2,
Loader2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityEntry, ActivityKind } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils";
/**
* Der Verlauf eines Projekts: was ist wann passiert.
*
* Die erste Seite kommt mit der Seite mit, weitere holt der Knopf nach. Die
* Alternative – alles gleich mitzuladen – wuerde jeden Seitenaufruf mit
* zweihundert Eintraegen belasten, von denen man selten mehr als die
* obersten fuenf liest.
*/
const KINDS: Record<ActivityKind, { icon: typeof Sparkles; color: string }> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" },
};
export function ActivityFeed({
projectId,
initial,
}: {
projectId: string;
initial: ActivityEntry[];
}) {
const [entries, setEntries] = useState(initial);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Kam eine volle Seite, gibt es womoeglich noch mehr.
const [complete, setComplete] = useState(initial.length < 20);
async function loadAll() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/projects/${projectId}/activity?limit=200`);
if (!response.ok) throw new Error("Verlauf konnte nicht geladen werden.");
const payload = await response.json();
setEntries(payload.activities as ActivityEntry[]);
setComplete(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
return (
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<History className="h-4 w-4 text-violet-300" />
Verlauf
</h2>
{entries.length === 0 ? (
<p className="mt-3 text-sm text-slate-500">
Noch nichts passiert. Sobald du etwas änderst, steht es hier.
</p>
) : (
<ol className="mt-4 space-y-0">
{entries.map((entry, index) => {
const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED;
const Icon = meta.icon;
const letzter = index === entries.length - 1;
return (
<li key={entry.id} className="relative flex gap-3 pb-4">
{/* Die Linie endet am letzten Eintrag, statt ins Leere zu laufen. */}
{!letzter ? (
<span
aria-hidden
className="absolute left-3 top-7 h-[calc(100%-1.25rem)] w-px bg-white/15"
/>
) : null}
<span className="relative z-10 mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[#0d1020] ring-1 ring-white/10">
<Icon className={`h-3.5 w-3.5 ${meta.color}`} />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{entry.summary}</p>
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500">
<time dateTime={new Date(entry.createdAt).toISOString()}>
{formatRelative(entry.createdAt)}
</time>
<span aria-hidden>·</span>
<span>{formatDateTime(entry.createdAt)}</span>
{/* Ohne Benutzer war es der automatische Abgleich. */}
{entry.user ? null : (
<>
<span aria-hidden>·</span>
<Bot className="h-3 w-3" aria-hidden />
<span>automatisch</span>
</>
)}
</p>
</div>
</li>
);
})}
</ol>
)}
{error ? <p className="mt-2 text-xs text-rose-300">{error}</p> : null}
{!complete ? (
<button
type="button"
onClick={loadAll}
disabled={loading}
className="btn btn-ghost mt-1 w-full text-xs"
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Älteres anzeigen
</button>
) : null}
</section>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Star, Tag, Trash2, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
/**
* Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist,
* und liegt fest am unteren Rand – bei einer langen Liste waere sie oben
* sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt.
*/
export function BulkBar({
ids,
onDone,
onClear,
}: {
ids: string[];
onDone: () => void;
onClear: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const statusRef = useRef<HTMLButtonElement>(null);
const tagsRef = useRef<HTMLButtonElement>(null);
async function apply(body: Record<string, unknown>) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
onDone();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
setStatusOpen(false);
setTagsOpen(false);
}
}
async function remove() {
if (
!window.confirm(
`${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` +
"unwiderruflich loeschen?",
)
) {
return;
}
await apply({ action: "delete" });
}
return (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-4">
<div className="popover pointer-events-auto flex flex-wrap items-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt
</span>
<span className="h-5 w-px bg-white/10" aria-hidden />
<button
ref={statusRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setStatusOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={statusOpen}
>
Status
</button>
<Popover
open={statusOpen}
anchorRef={statusRef}
onClose={() => setStatusOpen(false)}
align="start"
>
<div role="menu" className="min-w-[12rem] p-1">
{STATUS_ORDER.map((status) => (
<button
key={status}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-slate-300 hover:bg-white/6"
onClick={() => apply({ action: "status", status })}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_META[status].color }}
/>
{STATUS_META[status].label}
</button>
))}
</div>
</Popover>
<button
ref={tagsRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTagsOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={tagsOpen}
>
<Tag className="h-3.5 w-3.5" />
Tags
</button>
<Popover open={tagsOpen} anchorRef={tagsRef} onClose={() => setTagsOpen(false)} align="start">
<div className="w-64 space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
<input
id="bulk-tags"
className="field"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
placeholder="experiment, archiv"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
className="btn btn-primary flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "add" })
}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-ghost flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "remove" })
}
>
Entfernen
</button>
</div>
</div>
</Popover>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => apply({ action: "favorite", favorite: true })}
disabled={busy}
title="Als Favoriten markieren"
>
<Star className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Löschen
</button>
{busy ? <Loader2 className="h-4 w-4 animate-spin text-slate-400" /> : null}
<button
type="button"
onClick={onClear}
className="rounded-lg p-1.5 text-slate-500 hover:text-white"
aria-label="Auswahl aufheben"
>
<X className="h-4 w-4" />
</button>
{error ? (
<p className={cn("w-full px-1 text-xs text-rose-300")}>{error}</p>
) : null}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { CHANGELOG, KIND_META } from "@/lib/changelog";
import { cn, formatDate } from "@/lib/utils";
/**
* Die Versionsnummer in der Fusszeile oeffnet den Aenderungsverlauf.
*
* Kein eigener Menuepunkt: gesucht wird so etwas erfahrungsgemaess dort, wo
* die Version steht – und einen Platz in der Navigation ist es nicht wert.
*/
export function ChangelogButton({ version }: { version: string }) {
const [open, setOpen] = useState(false);
const aktuell = CHANGELOG[0]?.version;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded font-mono text-slate-600 underline decoration-dotted underline-offset-4 transition-colors hover:text-violet-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
title="Was ist neu?"
>
v{version}
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
width="lg"
title="Änderungsverlauf"
description={
// Ein Hinweis, falls die laufende Fassung neuer ist als der Eintrag.
// Das passiert, wenn jemand die Version hochzaehlt und den Eintrag
// vergisst – dann soll es auffallen statt still danebenzustehen.
aktuell === version
? "Was in den letzten Fassungen dazugekommen ist."
: `Diese Instanz läuft auf v${version}; der Verlauf endet bei v${aktuell}.`
}
>
<ol className="space-y-7">
{CHANGELOG.map((release) => (
<li key={release.version}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-mono text-base font-semibold text-white">
v{release.version}
</h3>
{release.version === version ? (
<span className="rounded-full bg-violet-500/15 px-2 py-0.5 text-[10px] uppercase tracking-wider text-violet-200 ring-1 ring-violet-400/30">
installiert
</span>
) : null}
<span className="text-xs text-slate-500">{formatDate(release.date)}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{release.headline}</p>
<ul className="mt-3 space-y-2">
{release.changes.map((change, index) => {
const meta = KIND_META[change.kind];
return (
<li key={index} className="flex gap-2.5 text-sm leading-snug text-slate-300">
<span
className={cn(
"mt-0.5 h-fit shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1",
meta.className,
)}
>
{meta.label}
</span>
<span className="min-w-0">{change.text}</span>
</li>
);
})}
</ul>
</li>
))}
</ol>
</Modal>
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { CircleCheck, CircleDashed, CircleSlash, CircleX, LoaderCircle } from "lucide-react";
import { cn } from "@/lib/utils";
export type CiState = "success" | "failure" | "running" | "pending" | "cancelled";
const META: Record<
CiState,
{ label: string; short: string; className: string; icon: typeof CircleCheck; spin?: boolean }
> = {
success: {
label: "CI erfolgreich",
short: "CI ok",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
icon: CircleCheck,
},
failure: {
label: "CI fehlgeschlagen",
short: "CI rot",
className: "bg-rose-500/12 text-rose-200 ring-rose-400/30",
icon: CircleX,
},
running: {
label: "CI laeuft",
short: "CI laeuft",
className: "bg-amber-500/12 text-amber-200 ring-amber-400/30",
icon: LoaderCircle,
spin: true,
},
pending: {
label: "CI wartet",
short: "CI wartet",
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
icon: CircleDashed,
},
cancelled: {
label: "CI abgebrochen",
short: "CI abgebrochen",
className: "bg-slate-500/12 text-slate-400 ring-slate-400/20",
icon: CircleSlash,
},
};
export function CiBadge({
state,
name,
url,
compact = false,
}: {
state: string | null | undefined;
name?: string | null;
url?: string | null;
compact?: boolean;
}) {
if (!state || !(state in META)) return null;
const meta = META[state as CiState];
const Icon = meta.icon;
const content = (
<>
<Icon className={cn("h-3 w-3 shrink-0", meta.spin && "animate-spin")} />
{compact ? meta.short : (name ?? meta.label)}
</>
);
const className = cn(
"inline-flex items-center gap-1.5 rounded-full ring-1 ring-inset",
meta.className,
compact ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-[11px]",
);
if (url && !compact) {
return (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className={cn(className, "transition-all hover:brightness-125")}
title={meta.label}
>
{content}
</a>
);
}
return (
<span className={className} title={name ? `${meta.label} · ${name}` : meta.label}>
{content}
</span>
);
}
+380
View File
@@ -0,0 +1,380 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
CornerDownLeft,
GitBranch,
LayoutDashboard,
Loader2,
NotebookPen,
Plus,
Search,
Settings,
UserRound,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { statusOf } from "@/lib/status";
import { cn } from "@/lib/utils";
/**
* Schnellsuche, zu erreichen mit Strg+K beziehungsweise Cmd+K.
*
* Springt zu einem Projekt, zu einer Notiz oder direkt in einen Bereich.
* Die Projektliste wird beim ersten Oeffnen geholt und dann behalten – sie
* bei jedem Tastendruck neu zu laden waere Verschwendung, und veraltet ist
* sie hoechstens um die Dauer einer Sitzung.
*/
interface ProjectHit {
id: string;
name: string;
slug: string;
summary: string | null;
status: ProjectStatus;
tags: string[];
}
interface NoteHit {
projectId: string;
noteId: string;
title: string | null;
snippet: string;
}
interface Action {
id: string;
label: string;
hint: string;
icon: typeof LayoutDashboard;
href: string;
adminOnly?: boolean;
}
const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
{
id: "connections",
label: "Git-Verbindungen",
hint: "GitHub, GitLab, Gitea",
icon: GitBranch,
href: "/connections",
},
{ id: "account", label: "Mein Konto", hint: "Profil, Passkeys", icon: UserRound, href: "/account" },
{
id: "notify",
label: "Benachrichtigungen",
hint: "Ziele verwalten",
icon: Bell,
href: "/account",
},
{
id: "admin",
label: "Admin-Einstellungen",
hint: "Betriebsart, Abgleich, Konten",
icon: Settings,
href: "/admin",
adminOnly: true,
},
];
type Entry =
| { kind: "action"; key: string; action: Action }
| { kind: "project"; key: string; project: ProjectHit }
| { kind: "note"; key: string; note: NoteHit; projectName: string };
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const router = useRouter();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [projects, setProjects] = useState<ProjectHit[] | null>(null);
const [notes, setNotes] = useState<NoteHit[]>([]);
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => setMounted(true), []);
// Strg+K oeffnet, Escape schliesst. In Eingabefeldern nicht stoeren –
// ausser beim Kuerzel selbst, das soll ueberall greifen.
useEffect(() => {
function onKey(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen((value) => !value);
} else if (event.key === "Escape") {
setOpen(false);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// Projektliste beim ersten Oeffnen holen.
useEffect(() => {
if (!open || projects !== null) return;
setLoading(true);
fetch("/api/projects")
.then((response) => (response.ok ? response.json() : { projects: [] }))
.then((body) => setProjects(body.projects ?? []))
.catch(() => setProjects([]))
.finally(() => setLoading(false));
}, [open, projects]);
// Notizen sucht der Server – entprellt, wie im Board.
useEffect(() => {
if (!open) return;
const needle = query.trim();
if (needle.length < 2) {
setNotes([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(needle)}`, {
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
setNotes(body.hits ?? []);
} catch {
// Abbruch beim Weitertippen ist der Normalfall.
}
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [open, query]);
useEffect(() => {
if (!open) {
setQuery("");
setNotes([]);
setActive(0);
}
}, [open]);
const entries = useMemo<Entry[]>(() => {
const needle = query.toLowerCase().trim();
const result: Entry[] = [];
const passendeAktionen = ACTIONS.filter(
(action) =>
(!action.adminOnly || isAdmin) &&
(!needle ||
action.label.toLowerCase().includes(needle) ||
action.hint.toLowerCase().includes(needle)),
);
const passendeProjekte = (projects ?? []).filter(
(project) =>
!needle ||
project.name.toLowerCase().includes(needle) ||
(project.summary ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)),
);
// Bei leerer Eingabe stehen die Projekte oben – danach sucht man
// normalerweise.
if (!needle) {
for (const project of passendeProjekte.slice(0, 6)) {
result.push({ kind: "project", key: `p-${project.id}`, project });
}
for (const action of passendeAktionen) {
result.push({ kind: "action", key: `a-${action.id}`, action });
}
return result;
}
for (const project of passendeProjekte.slice(0, 8)) {
result.push({ kind: "project", key: `p-${project.id}`, project });
}
for (const hit of notes.slice(0, 5)) {
result.push({
kind: "note",
key: `n-${hit.noteId}`,
note: hit,
projectName:
(projects ?? []).find((project) => project.id === hit.projectId)?.name ?? "Projekt",
});
}
for (const action of passendeAktionen) {
result.push({ kind: "action", key: `a-${action.id}`, action });
}
return result;
}, [query, projects, notes, isAdmin]);
useEffect(() => {
setActive((current) => Math.min(current, Math.max(0, entries.length - 1)));
}, [entries.length]);
const go = useCallback(
(entry: Entry) => {
setOpen(false);
if (entry.kind === "action") router.push(entry.action.href);
else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`);
else router.push(`/projects/${entry.note.projectId}`);
},
[router],
);
function onInputKey(event: React.KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActive((current) => (current + 1) % Math.max(1, entries.length));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((current) => (current - 1 + entries.length) % Math.max(1, entries.length));
} else if (event.key === "Enter") {
event.preventDefault();
const entry = entries[active];
if (entry) go(entry);
}
}
// Ausgewaehlten Eintrag im Blick behalten.
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>(`[data-index="${active}"]`)
?.scrollIntoView({ block: "nearest" });
}, [active]);
if (!mounted || !open) return null;
return createPortal(
<div className="fixed inset-0 z-[130] flex items-start justify-center p-4 pt-[12vh]">
<button
type="button"
aria-label="Schnellsuche schliessen"
onClick={() => setOpen(false)}
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-label="Schnellsuche"
className="popover fade-up relative z-10 w-full max-w-xl overflow-hidden rounded-2xl"
>
<div className="flex items-center gap-3 border-b border-white/8 px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-slate-500" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKey}
placeholder="Projekt, Notiz oder Bereich…"
className="w-full bg-transparent text-sm text-slate-100 outline-none placeholder:text-slate-600"
/>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-slate-600" /> : null}
</div>
<div ref={listRef} className="max-h-[22rem] overflow-y-auto p-1.5">
{entries.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-slate-500">
{loading ? "Lade…" : "Nichts gefunden."}
</p>
) : (
entries.map((entry, index) => (
<button
key={entry.key}
type="button"
data-index={index}
onMouseMove={() => setActive(index)}
onClick={() => go(entry)}
className={cn(
"flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left transition-colors",
index === active ? "bg-white/8" : "hover:bg-white/5",
)}
>
{entry.kind === "action" ? (
<>
<entry.action.icon className="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
<span className="min-w-0">
<span className="block truncate text-sm text-slate-200">
{entry.action.label}
</span>
<span className="block truncate text-[11px] text-slate-500">
{entry.action.hint}
</span>
</span>
</>
) : entry.kind === "project" ? (
<>
<span
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: statusOf(entry.project.status).color }}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-slate-200">
{entry.project.name}
</span>
<span className="block truncate text-[11px] text-slate-500">
{entry.project.summary ?? statusOf(entry.project.status).label}
</span>
</span>
<span className="mt-0.5 shrink-0 text-[10px] uppercase tracking-wider text-slate-600">
{statusOf(entry.project.status).short}
</span>
</>
) : (
<>
<NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" />
<span className="min-w-0">
<span className="block truncate text-sm text-slate-200">
{entry.note.title ?? "Notiz"}
<span className="ml-1.5 text-[11px] text-slate-500">
in {entry.projectName}
</span>
</span>
<span className="block truncate text-[11px] text-slate-500">
{entry.note.snippet.replace(/\[\[|\]\]/g, "")}
</span>
</span>
</>
)}
</button>
))
)}
</div>
<div className="flex items-center gap-4 border-t border-white/6 px-4 py-2 text-[10px] text-slate-600">
<span className="inline-flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
waehlen
</span>
<span className="inline-flex items-center gap-1">
<Kbd>
<CornerDownLeft className="h-2.5 w-2.5" />
</Kbd>
oeffnen
</span>
<span className="inline-flex items-center gap-1">
<Kbd>Esc</Kbd>
schliessen
</span>
<span className="ml-auto inline-flex items-center gap-1">
<Plus className="h-2.5 w-2.5" />
<Kbd>Strg</Kbd>
<Kbd>K</Kbd>
</span>
</div>
</div>
</div>,
document.body,
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex min-w-[1.2rem] items-center justify-center rounded border border-white/12 bg-white/5 px-1 py-0.5 font-sans text-[10px] text-slate-400">
{children}
</kbd>
);
}
+4 -4
View File
@@ -140,8 +140,8 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
} }
async function remove(connection: ConnectionWithCount) { async function remove(connection: ConnectionWithCount) {
const linked = connection._count?.projects ?? 0; const linked = connection._count?.repositories ?? 0;
const hint = linked > 0 ? `\n\n${linked} Projekt(e) verlieren die Verknuepfung.` : ""; const hint = linked > 0 ? `\n\n${linked} Repository(s) verlieren die Verknuepfung.` : "";
if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return; if (!window.confirm(`Verbindung „${connection.name}“ loeschen?${hint}`)) return;
setBusyId(connection.id); setBusyId(connection.id);
await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined); await fetch(`/api/connections/${connection.id}`, { method: "DELETE" }).catch(() => undefined);
@@ -257,9 +257,9 @@ export function ConnectionsManager({ connections }: { connections: ConnectionWit
</div> </div>
) : null} ) : null}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<dt className="text-slate-500">Projekte</dt> <dt className="text-slate-500">Repositories</dt>
<dd className="tabular-nums text-slate-300"> <dd className="tabular-nums text-slate-300">
{connection._count?.projects ?? 0} {connection._count?.repositories ?? 0}
</dd> </dd>
</div> </div>
{connection.lastCheckedAt ? ( {connection.lastCheckedAt ? (
+321 -35
View File
@@ -3,32 +3,46 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Activity,
AlertTriangle, AlertTriangle,
CircleDot,
ExternalLink, ExternalLink,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
GitMerge, GitMerge,
GitPullRequest, GitPullRequest,
HardDrive, HardDrive,
ListPlus,
Loader2, Loader2,
Lock, Lock,
Package, Package,
Plus,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
Star, Star,
Tag, Tag,
Unlink,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asRecord } from "@/types"; import { asArray, asRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
CommitInfo, CommitInfo,
ConnectionSafe,
ProjectDetail, ProjectDetail,
ReleaseInfo, ReleaseInfo,
RepositoryWithSnapshot,
TagInfo, TagInfo,
TicketInfo,
} from "@/types"; } from "@/types";
import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils"; import { cn, formatDateTime, formatNumber, formatRelative } from "@/lib/utils";
const TABS = [ const TABS = [
{ key: "pulls", label: "PRs", icon: GitPullRequest },
{ key: "issues", label: "Issues", icon: CircleDot },
{ key: "branches", label: "Branches", icon: GitBranch }, { key: "branches", label: "Branches", icon: GitBranch },
{ key: "tags", label: "Tags", icon: Tag }, { key: "tags", label: "Tags", icon: Tag },
{ key: "commits", label: "Commits", icon: GitCommitHorizontal }, { key: "commits", label: "Commits", icon: GitCommitHorizontal },
@@ -37,26 +51,74 @@ const TABS = [
type TabKey = (typeof TABS)[number]["key"]; type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ project }: { project: ProjectDetail }) { export function GitPanel({
project,
connections,
}: {
project: ProjectDetail;
connections: ConnectionSafe[];
}) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>("branches"); const [tab, setTab] = useState<TabKey>("pulls");
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [addOpen, setAddOpen] = useState(false);
const snapshot = project.snapshot; const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() {
if (!repository) return;
setImporting(true);
setMessage(null);
try {
const response = await fetch(`/api/projects/${project.id}/tasks/from-issues`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repositoryId: repository.id }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Uebernahme fehlgeschlagen.");
setMessage({
ok: true,
text:
body.created === 0
? `Nichts Neues – alle ${body.skipped} Issues sind bereits als Aufgabe vorhanden.`
: `${body.created} Aufgabe(n) angelegt${body.skipped ? `, ${body.skipped} uebersprungen` : ""}.`,
});
router.refresh();
} catch (caught) {
setMessage({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setImporting(false);
}
}
const snapshot = repository?.snapshot ?? null;
const branches = asArray<BranchInfo>(snapshot?.branches); const branches = asArray<BranchInfo>(snapshot?.branches);
const tags = asArray<TagInfo>(snapshot?.tags); const tags = asArray<TagInfo>(snapshot?.tags);
const commits = asArray<CommitInfo>(snapshot?.commits); const commits = asArray<CommitInfo>(snapshot?.commits);
const releases = asArray<ReleaseInfo>(snapshot?.releases); const releases = asArray<ReleaseInfo>(snapshot?.releases);
const languages = asRecord(snapshot?.languages); const languages = asRecord(snapshot?.languages);
const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity);
const hasRepo = Boolean(project.repoOwner && project.repoName) || Boolean(project.repoUrl); /** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) {
async function sync() {
setSyncing(true); setSyncing(true);
setMessage(null); setMessage(null);
try { try {
const response = await fetch(`/api/projects/${project.id}/sync`, { method: "POST" }); const url = nurDieses
? `/api/repositories/${nurDieses}/sync`
: `/api/projects/${project.id}/sync`;
const response = await fetch(url, { method: "POST" });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." }); setMessage({ ok: response.ok, text: payload.message ?? "Unbekannte Antwort." });
router.refresh(); router.refresh();
@@ -70,16 +132,41 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
} }
} }
if (!hasRepo) { async function removeRepository(entry: RepositoryWithSnapshot) {
if (
!window.confirm(
`„${repoTitle(entry)}“ vom Projekt loesen? Der geholte Stand geht dabei verloren.`,
)
) {
return;
}
await fetch(`/api/repositories/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setSelectedId(null);
router.refresh();
}
if (!repository) {
return ( return (
<div className="glass px-5 py-10 text-center"> <>
<GitBranch className="mx-auto h-8 w-8 text-slate-600" /> <div className="glass px-5 py-10 text-center">
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3> <GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500"> <h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um <p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Branches, Tags, Commits und Releases hier zu sehen. Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
</p> Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
</div> </p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" />
Repository verknuepfen
</button>
</div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
</>
); );
} }
@@ -90,12 +177,53 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Auswahl, sobald es mehr als eines gibt. Bei einem einzigen waere eine
Leiste mit genau einem Knopf nur Ballast. */}
{repositories.length > 1 ? (
<div className="glass flex flex-wrap items-center gap-1.5 p-2" role="tablist">
{repositories.map((entry) => {
const aktiv = entry.id === repository.id;
return (
<button
key={entry.id}
type="button"
role="tab"
aria-selected={aktiv}
onClick={() => setSelectedId(entry.id)}
className={cn(
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors",
aktiv
? "bg-white/9 text-white"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200",
)}
>
{entry.syncError ? (
<AlertTriangle className="h-3 w-3 text-rose-300" />
) : (
<GitBranch className="h-3 w-3 text-slate-500" />
)}
{repoTitle(entry)}
</button>
);
})}
<button
type="button"
onClick={() => setAddOpen(true)}
className="ml-auto rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/5 hover:text-violet-300"
aria-label="Weiteres Repository verknuepfen"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
) : null}
{/* Kopf mit Sync */} {/* Kopf mit Sync */}
<div className="glass flex flex-wrap items-center justify-between gap-3 p-4"> <div className="glass flex flex-wrap items-center justify-between gap-3 p-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="truncate font-mono text-sm text-slate-100"> <h2 className="truncate font-mono text-sm text-slate-100">
{snapshot?.fullName ?? `${project.repoOwner}/${project.repoName}`} {snapshot?.fullName ?? repoTitle(repository)}
</h2> </h2>
{snapshot?.visibility ? ( {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"> <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">
@@ -118,24 +246,70 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<ExternalLink className="h-3.5 w-3.5" /> <ExternalLink className="h-3.5 w-3.5" />
</a> </a>
) : null} ) : null}
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} />
</div> </div>
<p className="mt-1 text-xs text-slate-500" suppressHydrationWarning> <p className="mt-1 text-xs text-slate-500" suppressHydrationWarning>
{project.lastSyncedAt {repository.lastSyncedAt
? `Zuletzt synchronisiert ${formatRelative(project.lastSyncedAt)} · ${formatDateTime(project.lastSyncedAt)}` ? `Zuletzt synchronisiert ${formatRelative(repository.lastSyncedAt)} · ${formatDateTime(repository.lastSyncedAt)}`
: "Noch nie synchronisiert."} : "Noch nie synchronisiert."}
</p> </p>
</div> </div>
<button type="button" className="btn btn-primary" onClick={sync} disabled={syncing}> <div className="flex flex-wrap items-center gap-2">
{syncing ? ( {repositories.length > 1 ? (
<Loader2 className="h-4 w-4 animate-spin" /> <button
type="button"
className="btn btn-ghost"
onClick={() => sync()}
disabled={syncing}
title="Alle Repositories dieses Projekts abgleichen"
>
<RefreshCw className="h-4 w-4" />
Alle
</button>
) : ( ) : (
<RefreshCw className="h-4 w-4" /> <button
type="button"
onClick={() => setAddOpen(true)}
className="btn btn-ghost"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-4 w-4" />
Repository
</button>
)} )}
Jetzt synchronisieren <button
</button> type="button"
onClick={() => removeRepository(repository)}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-rose-500/10 hover:text-rose-300"
aria-label={`${repoTitle(repository)} vom Projekt loesen`}
title="Vom Projekt loesen"
>
<Unlink className="h-4 w-4" />
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => sync(repository.id)}
disabled={syncing}
>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Jetzt synchronisieren
</button>
</div>
</div> </div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
{message ? ( {message ? (
<p <p
className={cn( className={cn(
@@ -149,10 +323,10 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</p> </p>
) : null} ) : null}
{project.syncError && !message ? ( {repository.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"> <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" /> <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{project.syncError}</span> <span>{repository.syncError}</span>
</p> </p>
) : null} ) : null}
@@ -224,6 +398,24 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div> </div>
</div> </div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="glass p-4">
<div className="mb-2.5 flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<Activity className="h-3 w-3" />
{/* Der Zeitraum richtet sich danach, wie weit die geholten
Commits zurueckreichen – hoechstens 30 Tage. */}
Commits der letzten {activity.length} Tage
</p>
<span className="text-xs tabular-nums text-slate-400">
{activity.reduce((sum, value) => sum + value, 0)}
</span>
</div>
<Sparkline values={activity} height={40} />
</div>
) : null}
{/* Sprachen */} {/* Sprachen */}
{languageTotal > 0 ? ( {languageTotal > 0 ? (
<div className="glass p-4"> <div className="glass p-4">
@@ -263,14 +455,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<div className="glass overflow-hidden"> <div className="glass overflow-hidden">
<div className="flex flex-wrap gap-1 border-b border-white/6 p-2"> <div className="flex flex-wrap gap-1 border-b border-white/6 p-2">
{TABS.map(({ key, label, icon: Icon }) => { {TABS.map(({ key, label, icon: Icon }) => {
const count = const count = {
key === "branches" pulls: pulls.length,
? branches.length issues: issues.length,
: key === "tags" branches: branches.length,
? tags.length tags: tags.length,
: key === "commits" commits: commits.length,
? commits.length releases: releases.length,
: releases.length; }[key];
return ( return (
<button <button
key={key} key={key}
@@ -292,6 +484,39 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
</div> </div>
<div className="max-h-[26rem] overflow-y-auto"> <div className="max-h-[26rem] overflow-y-auto">
{tab === "pulls" ? (
<TicketList
tickets={pulls}
empty="Keine offenen Pull oder Merge Requests."
icon={GitPullRequest}
/>
) : null}
{tab === "issues" ? (
<>
{issues.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/6 bg-white/2 px-4 py-2">
<p className="text-[11px] text-slate-500">
Aus offenen Issues Aufgaben machen. Bereits uebernommene werden
uebersprungen.
</p>
<button
type="button"
className="btn btn-ghost px-2.5 py-1 text-[11px]"
onClick={importIssues}
disabled={importing}
>
{importing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<ListPlus className="h-3 w-3" />
)}
Als Aufgaben uebernehmen
</button>
</div>
) : null}
<TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
</>
) : null}
{tab === "branches" ? <BranchList branches={branches} /> : null} {tab === "branches" ? <BranchList branches={branches} /> : null}
{tab === "tags" ? <TagList tags={tags} /> : null} {tab === "tags" ? <TagList tags={tags} /> : null}
{tab === "commits" ? <CommitList commits={commits} /> : null} {tab === "commits" ? <CommitList commits={commits} /> : null}
@@ -342,6 +567,67 @@ function Empty({ text }: { text: string }) {
return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>; return <p className="px-4 py-8 text-center text-xs text-slate-500">{text}</p>;
} }
function TicketList({
tickets,
empty,
icon: Icon,
}: {
tickets: TicketInfo[];
empty: string;
icon: typeof GitPullRequest;
}) {
if (tickets.length === 0) return <Empty text={empty} />;
return (
<div>
{tickets.map((ticket) => (
<Row key={`${ticket.number}-${ticket.title}`}>
<div className="flex min-w-0 items-start gap-2">
<Icon
className={cn(
"mt-0.5 h-3.5 w-3.5 shrink-0",
ticket.draft ? "text-slate-600" : "text-emerald-400/70",
)}
/>
<div className="min-w-0">
<p className="truncate text-xs text-slate-200">
{ticket.title}
{ticket.draft ? (
<span className="ml-1.5 rounded bg-slate-500/15 px-1 py-0.5 text-[10px] text-slate-400">
Entwurf
</span>
) : null}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-slate-500">
<span className="font-mono text-slate-600">#{ticket.number}</span>
<span>{ticket.author ?? "unbekannt"}</span>
{ticket.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-[10px]">
{label}
</span>
))}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-[11px] text-slate-500">
<span suppressHydrationWarning>{formatRelative(ticket.createdAt)}</span>
{ticket.url ? (
<a
href={ticket.url}
target="_blank"
rel="noreferrer noopener"
className="text-slate-600 transition-colors hover:text-violet-300"
aria-label="Im Git-System oeffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Row>
))}
</div>
);
}
function BranchList({ branches }: { branches: BranchInfo[] }) { function BranchList({ branches }: { branches: BranchInfo[] }) {
if (branches.length === 0) return <Empty text="Keine Branches gefunden." />; if (branches.length === 0) return <Empty text="Keine Branches gefunden." />;
return ( return (
+389
View File
@@ -0,0 +1,389 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GitBranch, GripVertical, Loader2, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
type Columns = Record<ProjectStatus, ProjectListItem[]>;
/**
* Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
*
* Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
* gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
* nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
* Gruppen, hier zaehlt die Anordnung von Hand.
*
* Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert
* das Speichern, springt alles auf den Serverstand zurueck.
*/
export function KanbanBoard({
projects,
showArchived,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
showArchived: boolean;
selection: Set<string>;
onToggleSelect: (id: string, selected: boolean) => void;
}) {
const router = useRouter();
const [activeId, setActiveId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const sichtbar = useMemo(
() => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
[showArchived],
);
const group = useMemo(
() => (list: ProjectListItem[]): Columns => {
const leer = Object.fromEntries(
STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]),
) as Columns;
for (const project of list) leer[project.status].push(project);
for (const status of STATUS_ORDER) {
// Bei gleicher Position bleibt es bei der Reihenfolge, in der die
// Liste kam – das ist die im Kopf gewaehlte Sortierung.
leer[status].sort((a, b) => a.position - b.position);
}
return leer;
},
[],
);
const [columns, setColumns] = useState<Columns>(() => group(projects));
// Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann
// noch der alte und wuerde die Karte zurueckspringen lassen.
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setColumns(group(projects));
}, [projects, group]);
const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
// nicht mehr anklicken.
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const active = activeId
? Object.values(columns).flat().find((project) => project.id === activeId) ?? null
: null;
/** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
function containerOf(key: string): ProjectStatus | null {
if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus;
return (
STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null
);
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((project) => project.id === activeKey);
if (!moved) return current;
const zielIndex = current[to].findIndex((project) => project.id === overKey);
const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length;
return {
...current,
[from]: current[from].filter((project) => project.id !== activeKey),
[to]: [
...current[to].slice(0, einfuegenBei),
{ ...moved, status: to },
...current[to].slice(einfuegenBei),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const ueber = containerOf(overKey);
if (container && container === ueber) {
const list = columns[container];
const alt = list.findIndex((project) => project.id === activeKey);
const neu = list.findIndex((project) => project.id === overKey);
if (alt >= 0 && neu >= 0 && alt !== neu) {
const sortiert = [...list];
const [moved] = sortiert.splice(alt, 1);
sortiert.splice(neu, 0, moved);
next = { ...columns, [container]: sortiert };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
async function persist(next: Columns) {
setSaving(true);
try {
const response = await fetch("/api/projects/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
columns: Object.fromEntries(
sichtbar.map((status) => [status, next[status].map((project) => project.id)]),
),
}),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(projects));
} finally {
setSaving(false);
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(projects));
}}
>
{saving ? (
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Reihenfolge wird gespeichert…
</p>
) : null}
<div className="flex gap-3 overflow-x-auto pb-3">
{sichtbar.map((status) => (
<Column key={status} status={status} count={columns[status].length}>
<SortableContext
items={columns[status].map((project) => project.id)}
strategy={verticalListSortingStrategy}
>
{columns[status].map((project) => (
<KanbanCard
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</SortableContext>
</Column>
))}
</div>
{/* Die mitgezogene Karte haengt am Cursor, nicht in der Spalte. */}
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <KanbanCard project={active} overlay /> : null}
</DragOverlay>
</DndContext>
);
}
function Column({
status,
count,
children,
}: {
status: ProjectStatus;
count: number;
children: React.ReactNode;
}) {
const meta = STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<section
ref={setNodeRef}
// Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status}
className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<header className="flex items-center gap-2 px-3 py-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h3>
<span className="ml-auto rounded-full bg-white/6 px-1.5 py-0.5 text-[10px] tabular-nums text-slate-400">
{count}
</span>
</header>
<div className="flex min-h-[7rem] flex-1 flex-col gap-2 px-2 pb-2">
{children}
{count === 0 ? (
<p
className={cn(
"rounded-xl border border-dashed border-white/8 px-3 py-6 text-center text-[11px] text-slate-600 transition-colors",
isOver && "border-violet-400/40 text-violet-200",
)}
>
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</section>
);
}
function KanbanCard({
project,
overlay = false,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
overlay?: boolean;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
return (
<article
ref={overlay ? undefined : setNodeRef}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
} as React.CSSProperties
}
className={cn(
"popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
selected && "ring-1 ring-violet-400/60",
)}
>
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
<div className="flex items-start gap-2">
{/* Nur der Griff zieht – so bleibt der Titel ein normaler Link. */}
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-600 transition-colors hover:text-slate-300 active:cursor-grabbing"
aria-label={`${project.name} verschieben`}
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
aria-label={`${project.name} auswaehlen`}
/>
) : null}
<div className="min-w-0 flex-1">
<Link
href={`/projects/${project.id}`}
className="block truncate text-sm font-medium text-slate-100 hover:text-violet-200"
>
{project.name}
</Link>
{project.summary ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-slate-500">
{truncate(project.summary, 90)}
</p>
) : null}
</div>
</div>
<div className="mt-2.5 h-1 w-full overflow-hidden rounded-full bg-white/6">
<div
className="accent-bar h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
{/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */}
<CiBadge state={project.repositories[0]?.snapshot?.ciState} compact />
{project.repositories[0]?.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" />
{project.repositories[0].snapshot.defaultBranch}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
<span className="ml-auto tabular-nums">{project.progress}%</span>
</div>
</article>
);
}
+238 -6
View File
@@ -1,11 +1,14 @@
"use client"; "use client";
import { useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { import {
Columns3,
Database, Database,
Download, Download,
LayoutGrid, LayoutGrid,
ListFilter, ListFilter,
NotebookPen,
Plus, Plus,
Rows3, Rows3,
Search, Search,
@@ -13,7 +16,9 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog"; import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
@@ -21,8 +26,39 @@ import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types"; import type { ConnectionSafe, ProjectListItem } from "@/types";
interface NoteHit {
projectId: string;
noteId: string;
title: string | null;
/** Ausschnitt mit [[…]] um die Fundstelle – siehe /api/search. */
snippet: string;
}
/**
* Wandelt die [[…]]-Markierungen von ts_headline in hervorgehobene Stellen.
* Bewusst kein HTML aus der Datenbank einbetten – der Text stammt aus einer
* Notiz und damit vom Benutzer.
*/
function highlight(snippet: string): React.ReactNode[] {
return snippet.split(/(\[\[.*?\]\])/g).map((part, index) => {
if (part.startsWith("[[") && part.endsWith("]]")) {
return (
<mark key={index} className="rounded bg-violet-500/25 px-0.5 text-violet-100">
{part.slice(2, -2)}
</mark>
);
}
return <span key={index}>{part}</span>;
});
}
type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped"; type ViewMode = "grid" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "grouped", "kanban"];
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
const SORT_LABEL: Record<SortKey, string> = { const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert", updated: "Zuletzt geaendert",
@@ -44,10 +80,90 @@ export function ProjectBoard({
const [sort, setSort] = useState<SortKey>("updated"); const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid"); const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
/**
* Ansicht und Sortierung ueberdauern das Neuladen.
*
* Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch
* sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern
* gelesen: der Server kennt den gespeicherten Wert nicht, und ein
* Unterschied zwischen beiden waere ein Hydrationsfehler.
*
* Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen
* wiederherzustellen waere eher verwirrend als hilfreich.
*/
useEffect(() => {
try {
const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL);
if (!gespeichert) return;
const wert = JSON.parse(gespeichert) as Partial<{
view: ViewMode;
sort: SortKey;
showArchived: boolean;
}>;
if (wert.view && VIEWS.includes(wert.view)) setView(wert.view);
if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort);
if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived);
} catch {
// Kaputter oder gesperrter Speicher: dann eben die Voreinstellung.
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
ANSICHT_SCHLUESSEL,
JSON.stringify({ view, sort, showArchived }),
);
} catch {
// Im privaten Modus mancher Browser nicht beschreibbar – kein Grund,
// die Seite scheitern zu lassen.
}
}, [view, sort, showArchived]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null); const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
* unnoetig aufblaehen. Stattdessen sucht Postgres serverseitig, sobald der
* Begriff lang genug ist. Entprellt, damit nicht jeder Tastendruck eine
* Abfrage ausloest.
*/
useEffect(() => {
const needle = query.trim();
if (needle.length < 2) {
setNoteHits([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(needle)}`, {
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
setNoteHits(body.hits ?? []);
} catch {
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
const noteHitProjectIds = useMemo(
() => new Set(noteHits.map((hit) => hit.projectId)),
[noteHits],
);
const counts = useMemo(() => { const counts = useMemo(() => {
const map = new Map<ProjectStatus, number>(); const map = new Map<ProjectStatus, number>();
@@ -71,7 +187,11 @@ export function ProjectBoard({
(project.summary ?? "").toLowerCase().includes(needle) || (project.summary ?? "").toLowerCase().includes(needle) ||
(project.description ?? "").toLowerCase().includes(needle) || (project.description ?? "").toLowerCase().includes(needle) ||
project.tags.some((tag) => tag.includes(needle)) || project.tags.some((tag) => tag.includes(needle)) ||
(project.snapshot?.fullName ?? "").toLowerCase().includes(needle) project.repositories.some((repo) =>
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
) ||
// Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id)
); );
}); });
@@ -92,7 +212,7 @@ export function ProjectBoard({
}); });
return sorted; return sorted;
}, [projects, query, statusFilter, sort, showArchived]); }, [projects, query, statusFilter, sort, showArchived, noteHitProjectIds]);
const grouped = useMemo(() => { const grouped = useMemo(() => {
return STATUS_ORDER.map((status) => ({ return STATUS_ORDER.map((status) => ({
@@ -101,6 +221,28 @@ export function ProjectBoard({
})).filter((group) => group.items.length > 0); })).filter((group) => group.items.length > 0);
}, [visible]); }, [visible]);
/**
* Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine
* Massenaenderung Projekte, die man gar nicht mehr vor Augen hat.
*/
useEffect(() => {
setSelection((current) => {
if (current.size === 0) return current;
const sichtbar = new Set(visible.map((project) => project.id));
const next = new Set([...current].filter((id) => sichtbar.has(id)));
return next.size === current.size ? current : next;
});
}, [visible]);
function toggleSelect(id: string, selected: boolean) {
setSelection((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleStatus(status: ProjectStatus) { function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) => setStatusFilter((current) =>
current.includes(status) current.includes(status)
@@ -145,6 +287,14 @@ export function ProjectBoard({
className="field w-auto min-w-[11rem]" className="field w-auto min-w-[11rem]"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
// stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend.
disabled={view === "kanban"}
title={
view === "kanban"
? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst."
: undefined
}
> >
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ( {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -178,6 +328,18 @@ export function ProjectBoard({
> >
<Rows3 className="h-4 w-4" /> <Rows3 className="h-4 w-4" />
</button> </button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</button>
</div> </div>
<button <button
@@ -256,13 +418,69 @@ export function ProjectBoard({
</div> </div>
</div> </div>
{/* Treffer in Notizen */}
{noteHits.length > 0 ? (
<div className="glass fade-up p-4">
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<NotebookPen className="h-3.5 w-3.5 text-violet-300" />
Treffer in Notizen
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
{noteHits.length}
</span>
</h2>
<ul className="space-y-1">
{noteHits.slice(0, 8).map((hit) => {
const project = projects.find((entry) => entry.id === hit.projectId);
return (
<li key={hit.noteId}>
<Link
href={`/projects/${hit.projectId}`}
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
>
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm text-slate-200">
{project?.name ?? "Projekt"}
</span>
{hit.title ? (
<span className="text-[11px] text-slate-500">· {hit.title}</span>
) : null}
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
{highlight(hit.snippet)}
</span>
</Link>
</li>
);
})}
</ul>
{noteHits.length > 8 ? (
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
… und {noteHits.length - 8} weitere
</p>
) : null}
</div>
) : null}
{/* Inhalt */} {/* Inhalt */}
{visible.length === 0 ? ( {visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} /> <EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? (
<KanbanBoard
projects={visible}
showArchived={showArchived}
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => ( {visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -286,7 +504,13 @@ export function ProjectBoard({
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => ( {group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
</div> </div>
@@ -295,6 +519,14 @@ export function ProjectBoard({
</div> </div>
)} )}
{selection.size > 0 ? (
<BulkBar
ids={[...selection]}
onDone={() => setSelection(new Set())}
onClear={() => setSelection(new Set())}
/>
) : null}
<Popover <Popover
anchorRef={dataButtonRef} anchorRef={dataButtonRef}
open={dataMenuOpen} open={dataMenuOpen}
+77 -11
View File
@@ -4,16 +4,20 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { import {
AlertTriangle, AlertTriangle,
CheckSquare,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
NotebookPen, NotebookPen,
Star, Star,
Tag, Tag,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status"; import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatRelative, truncate } from "@/lib/utils"; import { cn, formatRelative, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types"; import { asArray, type ProjectListItem } from "@/types";
const PROVIDER_LABEL: Record<string, string> = { const PROVIDER_LABEL: Record<string, string> = {
GITHUB: "GitHub", GITHUB: "GitHub",
@@ -25,10 +29,28 @@ const PROVIDER_LABEL: Record<string, string> = {
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"), * Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck. * interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/ */
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { export function ProjectCard({
project,
index = 0,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
index?: number;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.snapshot; // Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
// insgesamt sind, sagt die Zahl daneben.
const leading = project.repositories[0] ?? null;
const snapshot = leading?.snapshot ?? null;
const weitere = project.repositories.length - 1;
// Ein Fehler an irgendeinem Repo gehoert auf die Karte, nicht nur einer am
// fuehrenden – sonst bliebe er unbemerkt.
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() { async function toggleFavorite() {
@@ -48,7 +70,10 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
return ( return (
<article <article
className="glass card-glow fade-up group relative overflow-hidden p-5" className={cn(
"glass card-glow fade-up group relative overflow-hidden p-5",
selected && "ring-1 ring-violet-400/60",
)}
style={ style={
{ {
"--accent-from": accent.from, "--accent-from": accent.from,
@@ -69,6 +94,21 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Gedaempft, aber immer da. Nur beim Ueberfahren einzublenden
waere auf einem Touchgeraet gar nicht erreichbar – und wer
es nie sieht, weiss nicht, dass es die Auswahl gibt. */}
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-3.5 w-3.5 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200"> <h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name} {project.name}
</h3> </h3>
@@ -110,12 +150,31 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</div> </div>
</div> </div>
{/* Commit-Aktivitaet */}
{activity.some((value) => value > 0) ? (
<div className="mt-3">
<Sparkline values={activity} height={20} />
</div>
) : null}
{/* Git-Kurzinfo */} {/* Git-Kurzinfo */}
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400"> <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-[11px] text-slate-400">
{project.connection ? ( <CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
{leading?.connection ? (
<span className="glass-soft inline-flex items-center gap-1.5 px-2 py-1"> <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" /> <span className="h-1.5 w-1.5 rounded-full bg-violet-400" aria-hidden="true" />
{PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider} {PROVIDER_LABEL[leading.connection.provider] ?? leading.connection.provider}
</span>
) : null}
{weitere > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={project.repositories.map((repo) => repoTitle(repo)).join(", ")}
>
<GitBranch className="h-3.5 w-3.5 text-slate-500" />+{weitere} Repo
{weitere === 1 ? "" : "s"}
</span> </span>
) : null} ) : null}
@@ -143,6 +202,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span> </span>
) : null} ) : null}
{project._count.tasks > 0 ? (
<span
className="inline-flex items-center gap-1.5"
title={`${project.tasks.length} von ${project._count.tasks} Aufgaben erledigt`}
>
<CheckSquare className="h-3.5 w-3.5 text-slate-500" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? ( {project._count.notes > 0 ? (
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<NotebookPen className="h-3.5 w-3.5 text-slate-500" /> <NotebookPen className="h-3.5 w-3.5 text-slate-500" />
@@ -150,11 +219,8 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
</span> </span>
) : null} ) : null}
{project.syncError ? ( {syncError ? (
<span <span className="inline-flex items-center gap-1.5 text-rose-300/80" title={syncError}>
className="inline-flex items-center gap-1.5 text-rose-300/80"
title={project.syncError}
>
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
Sync-Fehler Sync-Fehler
</span> </span>
+29 -6
View File
@@ -32,6 +32,7 @@ interface FormState {
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
autoSync: boolean; autoSync: boolean;
progressFromTasks: boolean;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -49,6 +50,7 @@ const EMPTY: FormState = {
repoOwner: "", repoOwner: "",
repoName: "", repoName: "",
autoSync: true, autoSync: true,
progressFromTasks: false,
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -85,11 +87,15 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
accent: project.accent, accent: project.accent,
tags: project.tags.join(", "), tags: project.tags.join(", "),
favorite: project.favorite, favorite: project.favorite,
connectionId: project.connectionId ?? "", // Der Dialog bearbeitet das fuehrende Repository. Weitere
repoUrl: project.repoUrl ?? "", // verwaltet das Git-Panel – hier wuerde eine Liste den Dialog
repoOwner: project.repoOwner ?? "", // sprengen, in dem es eigentlich um das Projekt geht.
repoName: project.repoName ?? "", connectionId: project.repositories[0]?.connectionId ?? "",
autoSync: project.autoSync, repoUrl: project.repositories[0]?.repoUrl ?? "",
repoOwner: project.repositories[0]?.repoOwner ?? "",
repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks,
} }
: EMPTY, : EMPTY,
); );
@@ -158,6 +164,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoOwner: form.repoOwner, repoOwner: form.repoOwner,
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync, autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks,
}; };
try { try {
@@ -291,9 +298,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5} step={5}
value={form.progress} value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))} onChange={(event) => update("progress", Number(event.target.value))}
className="range" disabled={form.progressFromTasks}
className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")}
style={{ "--fill": `${form.progress}%` } as React.CSSProperties} style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
/> />
<label className="mt-2 flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.progressFromTasks}
onChange={(event) => update("progressFromTasks", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aus Aufgaben berechnen
<span className="mt-0.5 block text-[11px] text-slate-600">
Der Fortschritt ergibt sich dann aus dem Anteil erledigter Aufgaben und
laesst sich nicht mehr von Hand schieben.
</span>
</span>
</label>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
+32 -20
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
CalendarDays, CalendarDays,
CheckSquare,
Download, Download,
ExternalLink, ExternalLink,
FileJson, FileJson,
@@ -18,6 +19,7 @@ import {
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta";
import { PRIORITY_META, accentOf } from "@/lib/status"; import { PRIORITY_META, accentOf } from "@/lib/status";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
import type { ConnectionSafe, ProjectDetail } from "@/types"; import type { ConnectionSafe, ProjectDetail } from "@/types";
@@ -131,24 +133,22 @@ export function ProjectHeader({
angelegt {formatDate(project.createdAt)} angelegt {formatDate(project.createdAt)}
</span> </span>
<span style={{ color: priority.color }}>Prioritaet: {priority.label}</span> <span style={{ color: priority.color }}>Prioritaet: {priority.label}</span>
{project.connection ? ( {/* Alle Repositories verlinken – bei einem einzigen sieht das aus
<span className="inline-flex items-center gap-1.5"> wie vorher, bei mehreren steht jedes fuer sich. */}
{project.connection.name} {project.repositories
<span className="text-slate-700">·</span> .filter((repository) => repository.repoUrl)
{project.connection.provider} .map((repository) => (
</span> <a
) : null} key={repository.id}
{project.repoUrl ? ( href={repository.repoUrl!}
<a target="_blank"
href={project.repoUrl} rel="noreferrer noopener"
target="_blank" className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
rel="noreferrer noopener" >
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200" {project.repositories.length === 1 ? "Repository" : repoTitle(repository)}
> <ExternalLink className="h-3 w-3" />
Repository </a>
<ExternalLink className="h-3 w-3" /> ))}
</a>
) : null}
</div> </div>
{project.tags.length > 0 ? ( {project.tags.length > 0 ? (
@@ -207,7 +207,15 @@ export function ProjectHeader({
{/* Fortschritt direkt regelbar */} {/* Fortschritt direkt regelbar */}
<div className="mt-6"> <div className="mt-6">
<div className="mb-2 flex items-center justify-between text-xs text-slate-500"> <div className="mb-2 flex items-center justify-between text-xs text-slate-500">
<span>Fortschritt</span> <span className="flex items-center gap-1.5">
Fortschritt
{project.progressFromTasks ? (
<span className="inline-flex items-center gap-1 rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400">
<CheckSquare className="h-2.5 w-2.5" />
aus Aufgaben
</span>
) : null}
</span>
<span className="tabular-nums text-slate-300">{progress}%</span> <span className="tabular-nums text-slate-300">{progress}%</span>
</div> </div>
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem {/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
@@ -223,7 +231,11 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })} onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })} onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen" aria-label="Fortschritt anpassen"
className="range" // Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier
// wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder
// ueberschrieben.
disabled={project.progressFromTasks}
className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")}
style={{ "--fill": `${progress}%` } as React.CSSProperties} style={{ "--fill": `${progress}%` } as React.CSSProperties}
/> />
</div> </div>
+334
View File
@@ -0,0 +1,334 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import type { ConnectionSafe, RepoListEntry, RepositoryWithSnapshot } from "@/types";
/**
* Ein weiteres Repository mit einem Projekt verknuepfen – oder ein
* vorhandenes umbenennen und umhaengen.
*
* Beim Anlegen des Projekts genuegt der Projektdialog: dort setzen die
* Git-Felder das erste Repository. Alles darueber hinaus laeuft hier.
*/
interface FormState {
label: string;
connectionId: string;
repoUrl: string;
repoOwner: string;
repoName: string;
autoSync: boolean;
}
const EMPTY: FormState = {
label: "",
connectionId: "",
repoUrl: "",
repoOwner: "",
repoName: "",
autoSync: true,
};
export function RepositoryDialog({
open,
onClose,
projectId,
connections,
repository,
}: {
open: boolean;
onClose: () => void;
projectId: string;
connections: ConnectionSafe[];
/** Vorhandenes = Bearbeiten, sonst Verknuepfen. */
repository?: RepositoryWithSnapshot | null;
}) {
const router = useRouter();
const [form, setForm] = useState<FormState>(EMPTY);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const isEdit = Boolean(repository);
useEffect(() => {
if (!open) return;
setError(null);
setPickerOpen(false);
setRepos([]);
setFilter("");
setForm(
repository
? {
label: repository.label ?? "",
connectionId: repository.connectionId ?? "",
repoUrl: repository.repoUrl ?? "",
repoOwner: repository.repoOwner ?? "",
repoName: repository.repoName ?? "",
autoSync: repository.autoSync,
}
: EMPTY,
);
}, [open, repository]);
const gefiltert = useMemo(() => {
const needle = filter.toLowerCase().trim();
if (!needle) return repos.slice(0, 60);
return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60);
}, [repos, filter]);
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((previous) => ({ ...previous, [key]: value }));
}
async function loadRepos() {
if (!form.connectionId) {
setError("Bitte zuerst eine Git-Verbindung auswaehlen.");
return;
}
setLoading(true);
setError(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 ?? []);
setPickerOpen(true);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}
function pick(repo: RepoListEntry) {
setForm((previous) => ({
...previous,
repoUrl: repo.htmlUrl,
repoOwner: repo.owner,
repoName: repo.name,
}));
setPickerOpen(false);
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch(
isEdit ? `/api/repositories/${repository?.id}` : `/api/projects/${projectId}/repositories`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
label: form.label,
connectionId: form.connectionId || null,
repoUrl: form.repoUrl,
repoOwner: form.repoOwner,
repoName: form.repoName,
autoSync: form.autoSync,
}),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={isEdit ? "Repository bearbeiten" : "Repository verknuepfen"}
description={
isEdit
? "Zeigt das Repository woandershin, wird der geholte Stand verworfen."
: "Ein Projekt darf mehrere Repositories haben – etwa Frontend und Backend."
}
>
<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="repo-label">
Bezeichnung (optional)
</label>
<input
id="repo-label"
className="field"
value={form.label}
onChange={(event) => update("label", event.target.value)}
placeholder="Frontend"
maxLength={80}
/>
<p className="mt-1 text-xs text-slate-500">
Ohne Angabe steht schlicht owner/name da.
</p>
</div>
<div>
<label className="field-label" htmlFor="repo-connection">
Verbindung
</label>
<select
id="repo-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>
</div>
<div className="flex items-end">
<button
type="button"
className="btn btn-ghost w-full"
onClick={loadRepos}
disabled={loading || !form.connectionId}
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Repos laden
</button>
</div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="repo-url">
Repository-URL
</label>
<input
id="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="repo-owner">
Owner / Gruppe
</label>
<input
id="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="repo-name">
Repository
</label>
<input
id="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>
{pickerOpen ? (
<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={filter}
onChange={(event) => setFilter(event.target.value)}
/>
</div>
<ul className="max-h-56 overflow-y-auto p-1">
{gefiltert.map((repo) => (
<li key={repo.fullPath}>
<button
type="button"
onClick={() => pick(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>
))}
{gefiltert.length === 0 ? (
<li className="px-3 py-4 text-center text-xs text-slate-500">Keine Treffer.</li>
) : null}
</ul>
</div>
) : null}
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Verknuepfen"}
</button>
</div>
</form>
</Modal>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { cn } from "@/lib/utils";
/**
* Commit-Aktivitaet als kleine Balkenreihe.
*
* Bewusst als reines SVG ohne Diagrammbibliothek: es sind dreissig Werte,
* die Achsen, Legende und Tooltips einer Bibliothek waeren hier nur Ballast.
*/
export function Sparkline({
values,
className,
height = 28,
label = "Commits",
}: {
values: number[];
className?: string;
height?: number;
label?: string;
}) {
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
if (total === 0) return null;
const max = Math.max(...values);
const gap = 1;
const barWidth = 3;
const width = values.length * (barWidth + gap) - gap;
return (
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className={cn("w-full", className)}
style={{ height }}
role="img"
aria-label={`${label} der letzten ${values.length} Tage: insgesamt ${total}`}
>
<title>{`${label} der letzten ${values.length} Tage: insgesamt ${total}`}</title>
{values.map((value, index) => {
// Auch ein einzelner Commit soll sichtbar sein, daher die Untergrenze.
const barHeight = value === 0 ? 1.5 : Math.max(3, (value / max) * height);
return (
<rect
key={index}
x={index * (barWidth + gap)}
y={height - barHeight}
width={barWidth}
height={barHeight}
rx={1}
className={value === 0 ? "fill-white/10" : "fill-violet-400/80"}
/>
);
})}
</svg>
);
}
+3
View File
@@ -108,6 +108,9 @@ function UserMenu({ user }: { user: SessionUser }) {
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" 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-haspopup="menu"
aria-expanded={open} aria-expanded={open}
// Auf schmalen Anzeigen bleibt nur das Kuerzel sichtbar – ohne
// Beschriftung liest ein Screenreader dann nur zwei Buchstaben vor.
aria-label={`Benutzermenue fuer ${label}`}
> >
<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"> <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} {initials}
@@ -14,6 +14,7 @@ import {
ShieldCheck, ShieldCheck,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { TotpSection, type TotpState } from "@/components/account/TotpSection";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants"; import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
import { cn, formatDateTime, formatRelative } from "@/lib/utils"; import { cn, formatDateTime, formatRelative } from "@/lib/utils";
@@ -43,6 +44,7 @@ export interface AccountData {
passkeys: PasskeyEntry[]; passkeys: PasskeyEntry[];
sessions: SessionEntry[]; sessions: SessionEntry[];
passkeysAvailable: boolean; passkeysAvailable: boolean;
totp: TotpState;
} }
type Notice = { ok: boolean; text: string } | null; type Notice = { ok: boolean; text: string } | null;
@@ -52,6 +54,7 @@ export function AccountManager({ data }: { data: AccountData }) {
<div className="space-y-6"> <div className="space-y-6">
<ProfileSection data={data} /> <ProfileSection data={data} />
<PasswordSection hasPassword={data.hasPassword} /> <PasswordSection hasPassword={data.hasPassword} />
<TotpSection state={data.totp} hasPassword={data.hasPassword} />
<PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} /> <PasskeySection passkeys={data.passkeys} available={data.passkeysAvailable} />
<SessionSection sessions={data.sessions} /> <SessionSection sessions={data.sessions} />
</div> </div>
@@ -0,0 +1,515 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import {
Bell,
BellRing,
CheckCircle2,
KeyRound,
Loader2,
Mail,
Pencil,
Plus,
Trash2,
Webhook,
XCircle,
} from "lucide-react";
import type { NotificationChannel, NotificationEvent } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { EVENT_META, NOTIFICATION_EVENTS } from "@/lib/notify/types";
import { cn, formatRelative } from "@/lib/utils";
export interface NotificationTargetEntry {
id: string;
name: string;
channel: NotificationChannel;
target: string;
secretHint: string | null;
events: NotificationEvent[];
isActive: boolean;
lastSentAt: string | Date | null;
lastOk: boolean | null;
lastMessage: string | null;
}
const CHANNEL_META: Record<
NotificationChannel,
{ label: string; icon: typeof Webhook; hint: string; placeholder: string }
> = {
NTFY: {
label: "ntfy",
icon: BellRing,
hint: "Vollstaendige Themen-URL, z. B. https://ntfy.sh/mein-geheimes-thema",
placeholder: "https://ntfy.sh/mein-thema",
},
WEBHOOK: {
label: "Webhook",
icon: Webhook,
hint: "Bekommt JSON per POST. Mit Geheimnis wird der Rumpf signiert (X-Nebula-Signature).",
placeholder: "https://mein-dienst.de/haken",
},
EMAIL: {
label: "E-Mail",
icon: Mail,
hint: "Braucht SMTP-Zugangsdaten in der .env der Instanz.",
placeholder: "ich@example.com",
},
};
export function NotificationSection({
targets,
emailAvailable,
}: {
targets: NotificationTargetEntry[];
emailAvailable: boolean;
}) {
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<NotificationTargetEntry | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null);
async function test(entry: NotificationTargetEntry) {
setBusyId(entry.id);
setNotice(null);
try {
const response = await fetch(`/api/notifications/${entry.id}/test`, { method: "POST" });
const body = await response.json().catch(() => ({}));
setNotice({ ok: response.ok, text: body.message ?? body.error ?? "Unbekannte Antwort." });
router.refresh();
} finally {
setBusyId(null);
}
}
async function remove(entry: NotificationTargetEntry) {
if (!window.confirm(`Ziel „${entry.name}“ entfernen?`)) return;
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, { method: "DELETE" }).catch(() => undefined);
setBusyId(null);
router.refresh();
}
async function toggle(entry: NotificationTargetEntry) {
setBusyId(entry.id);
await fetch(`/api/notifications/${entry.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: !entry.isActive }),
}).catch(() => undefined);
setBusyId(null);
router.refresh();
}
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-violet-500/12 text-violet-300">
<Bell className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Benachrichtigungen
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Meldung bei fehlgeschlagenem Abgleich, roter CI oder neuem Release. Gemeldet
wird nur bei einer <strong className="text-slate-300">Aenderung</strong> –
nicht bei jedem Durchlauf.
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="h-3.5 w-3.5" />
Ziel hinzufuegen
</button>
</div>
{targets.length === 0 ? (
<p className="rounded-xl border border-dashed border-white/8 px-4 py-6 text-center text-sm text-slate-500">
Noch kein Ziel eingerichtet.
</p>
) : (
<div className="space-y-2">
{targets.map((entry) => {
const meta = CHANNEL_META[entry.channel];
const Icon = meta.icon;
return (
<div key={entry.id} className="glass-soft px-3 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-sm text-slate-100">
<Icon className="h-3.5 w-3.5 shrink-0 text-violet-300" />
{entry.name}
<span className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
{meta.label}
</span>
{!entry.isActive ? (
<span className="rounded bg-slate-500/15 px-1.5 py-0.5 text-[10px] text-slate-400">
pausiert
</span>
) : null}
{entry.secretHint ? (
<span className="inline-flex items-center gap-1 font-mono text-[10px] text-amber-300/70">
<KeyRound className="h-2.5 w-2.5" />
{entry.secretHint}
</span>
) : null}
</p>
<p className="mt-0.5 truncate font-mono text-[11px] text-slate-500">
{entry.target}
</p>
<p className="mt-1.5 flex flex-wrap gap-1">
{entry.events.map((event) => (
<span
key={event}
className="rounded bg-white/6 px-1.5 py-0.5 text-[10px] text-slate-400"
>
{EVENT_META[event].emoji} {EVENT_META[event].label}
</span>
))}
</p>
{entry.lastSentAt ? (
<p
className={cn(
"mt-1.5 flex items-center gap-1.5 text-[11px]",
entry.lastOk ? "text-emerald-200/80" : "text-rose-200/80",
)}
suppressHydrationWarning
>
{entry.lastOk ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
{formatRelative(entry.lastSentAt)} · {entry.lastMessage}
</p>
) : null}
</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"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => test(entry)}
>
Testen
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => toggle(entry)}
title={entry.isActive ? "Pausieren" : "Fortsetzen"}
>
{entry.isActive ? "Pause" : "An"}
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white/8 hover:text-violet-300"
onClick={() => {
setEditing(entry);
setDialogOpen(true);
}}
aria-label="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-rose-500/12 hover:text-rose-300"
onClick={() => remove(entry)}
aria-label="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{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}
{!emailAvailable ? (
<p className="mt-3 text-[11px] text-slate-600">
E-Mail steht nicht bereit: In der <code className="text-violet-300">.env</code> der
Instanz fehlt <code className="text-violet-300">SMTP_HOST</code>. ntfy und Webhook
funktionieren ohne weitere Einrichtung.
</p>
) : null}
<TargetDialog
open={dialogOpen}
entry={editing}
emailAvailable={emailAvailable}
onClose={() => {
setDialogOpen(false);
setEditing(null);
}}
/>
</section>
);
}
function TargetDialog({
open,
entry,
emailAvailable,
onClose,
}: {
open: boolean;
entry: NotificationTargetEntry | null;
emailAvailable: boolean;
onClose: () => void;
}) {
const router = useRouter();
const [name, setName] = useState("");
const [channel, setChannel] = useState<NotificationChannel>("NTFY");
const [target, setTarget] = useState("");
const [secret, setSecret] = useState("");
const [events, setEvents] = useState<NotificationEvent[]>(["SYNC_FAILED", "CI_FAILED"]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(entry);
useEffect(() => {
if (!open) return;
setName(entry?.name ?? "");
setChannel(entry?.channel ?? "NTFY");
setTarget(entry?.target ?? "");
setSecret("");
setEvents(entry?.events ?? ["SYNC_FAILED", "CI_FAILED"]);
setError(null);
setFieldErrors({});
}, [open, entry]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
setFieldErrors({});
const body: Record<string, unknown> = { name, target, events };
if (!isEdit) body.channel = channel;
if (!isEdit || secret.trim()) body.secret = secret;
try {
const response = await fetch(
isEdit ? `/api/notifications/${entry?.id}` : "/api/notifications",
{
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 {
setBusy(false);
}
}
const meta = CHANNEL_META[channel];
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Ziel bearbeiten" : "Neues Benachrichtigungsziel"}
description={
isEdit ? "Der Zustellweg selbst laesst sich nicht mehr aendern." : undefined
}
>
<form onSubmit={submit} className="space-y-4">
{!isEdit ? (
<div>
<span className="field-label">Weg</span>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(CHANNEL_META) as NotificationChannel[]).map((value) => {
const option = CHANNEL_META[value];
const Icon = option.icon;
const disabled = value === "EMAIL" && !emailAvailable;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => setChannel(value)}
title={disabled ? "Kein SMTP in der .env hinterlegt" : undefined}
className={cn(
"rounded-xl border px-3 py-2.5 text-sm transition-all disabled:opacity-40",
channel === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<Icon className="h-3.5 w-3.5" />
{option.label}
</span>
</button>
);
})}
</div>
</div>
) : null}
<div>
<label className="field-label" htmlFor="notify-name">
Name *
</label>
<input
id="notify-name"
className="field"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Handy"
required
maxLength={60}
/>
{fieldErrors.name ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.name}</p>
) : null}
</div>
<div>
<label className="field-label" htmlFor="notify-target">
{channel === "EMAIL" ? "E-Mail-Adresse *" : "URL *"}
</label>
<input
id="notify-target"
className="field font-mono text-xs"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={meta.placeholder}
required
/>
<p className="mt-1 text-[11px] text-slate-500">{meta.hint}</p>
{fieldErrors.target ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.target}</p>
) : null}
</div>
{channel !== "EMAIL" ? (
<div>
<label className="field-label" htmlFor="notify-secret">
Geheimnis {isEdit ? "(leer = unveraendert)" : "(optional)"}
</label>
<input
id="notify-secret"
className="field font-mono text-xs"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
placeholder={isEdit ? "••••••••" : ""}
/>
<p className="mt-1 text-[11px] text-slate-500">
{channel === "NTFY"
? "Wird als Bearer-Token gesendet – fuer geschuetzte Themen."
: "Signiert den Rumpf per HMAC-SHA256 im Kopf X-Nebula-Signature."}
</p>
</div>
) : null}
<div>
<span className="field-label">Anlaesse *</span>
<div className="space-y-1.5">
{NOTIFICATION_EVENTS.map((event) => {
const option = EVENT_META[event];
const checked = events.includes(event);
return (
<label
key={event}
className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-white/8 bg-black/20 px-3 py-2"
>
<input
type="checkbox"
checked={checked}
onChange={(e) =>
setEvents((current) =>
e.target.checked
? [...current, event]
: current.filter((value) => value !== event),
)
}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">
{option.emoji} {option.label}
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
{option.description}
</span>
</span>
</label>
);
})}
</div>
{fieldErrors.events ? (
<p className="mt-1 text-xs text-rose-300">{fieldErrors.events}</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}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || events.length === 0}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
Speichern
</button>
</div>
</form>
</Modal>
);
}
+375
View File
@@ -0,0 +1,375 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Check,
Copy,
Download,
Loader2,
RefreshCw,
ShieldCheck,
ShieldOff,
Smartphone,
} from "lucide-react";
import { cn, formatDateTime } from "@/lib/utils";
/**
* Zwei-Faktor-Anmeldung per Einmalkennwort.
*
* Die Einrichtung ist bewusst zweistufig: erst der QR-Code, dann ein Code zur
* Bestaetigung. Scharf wird der Schluessel erst danach – sonst waere jemand,
* der den QR-Code nicht scannen konnte, beim naechsten Anmelden ausgesperrt.
*/
export interface TotpState {
enabled: boolean;
enabledAt: string | Date | null;
recoveryCodesLeft: number;
}
type Notice = { ok: boolean; text: string } | null;
export function TotpSection({
state,
hasPassword,
}: {
state: TotpState;
hasPassword: boolean;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
// Einrichtung
const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null);
const [code, setCode] = useState("");
// Wird genau einmal angezeigt, direkt nach dem Erzeugen.
const [codes, setCodes] = useState<string[] | null>(null);
// Abschalten und Neuerzeugen verlangen beides das Passwort.
const [password, setPassword] = useState("");
const [confirming, setConfirming] = useState<"disable" | "recovery" | null>(null);
async function call(url: string, method: string, body?: unknown) {
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
return payload;
}
async function start() {
setBusy(true);
setNotice(null);
try {
const payload = await call("/api/account/totp", "POST");
setSetup({ qr: payload.qr, secret: payload.secret });
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
async function confirm(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setNotice(null);
try {
const payload = await call("/api/account/totp", "PUT", { code });
setSetup(null);
setCode("");
setCodes(payload.recoveryCodes);
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
async function submitPassword(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setNotice(null);
try {
if (confirming === "disable") {
await call("/api/account/totp", "DELETE", { password });
setNotice({ ok: true, text: "Die Zwei-Faktor-Anmeldung ist abgeschaltet." });
setCodes(null);
} else {
const payload = await call("/api/account/totp/recovery", "POST", { password });
setCodes(payload.recoveryCodes);
setNotice({ ok: true, text: "Neue Codes erzeugt. Die alten gelten nicht mehr." });
}
setConfirming(null);
setPassword("");
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex items-start gap-3">
<span
className={cn(
"grid h-9 w-9 shrink-0 place-items-center rounded-xl",
state.enabled
? "bg-emerald-500/12 text-emerald-300"
: "bg-violet-500/12 text-violet-300",
)}
>
<ShieldCheck className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Zwei-Faktor-Anmeldung
</h2>
<p className="mt-1 text-sm text-slate-400">
{state.enabled
? `Aktiv seit ${formatDateTime(state.enabledAt)}. Nach dem Passwort fragt die Anmeldung nach einem Code.`
: "Ein zeitbasiertes Einmalkennwort zusätzlich zum Passwort — aus einer Authenticator-App."}
</p>
</div>
</div>
{!hasPassword ? (
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
Dieses Konto hat kein Passwort und meldet sich per Passkey an. Ein zweiter Faktor
käme dabei nie zum Einsatz — der Passkey ist bereits an dein Gerät gebunden.
</p>
) : null}
{/* Frisch erzeugte Wiederherstellungscodes, einmalige Anzeige. */}
{codes ? <RecoveryCodes codes={codes} onDone={() => setCodes(null)} /> : null}
{hasPassword && !state.enabled && !setup && !codes ? (
<button type="button" onClick={start} disabled={busy} className="btn btn-primary">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Smartphone className="h-4 w-4" />}
Einrichten
</button>
) : null}
{/* Schritt 1: QR-Code scannen, Schritt 2: Code bestaetigen. */}
{setup ? (
<form onSubmit={confirm} className="space-y-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, kein Netzabruf */}
<img
src={setup.qr}
alt="QR-Code für die Authenticator-App"
width={160}
height={160}
className="shrink-0 rounded-xl bg-white p-2"
/>
<div className="min-w-0 space-y-2 text-sm text-slate-400">
<p>
Scanne den Code mit deiner Authenticator-App — etwa Aegis, 2FAS, Ente Auth
oder dem Passwortmanager deiner Wahl.
</p>
<p>Geht die Kamera nicht, trage den Schlüssel von Hand ein:</p>
<code className="block break-all rounded-lg border border-white/8 bg-black/30 px-3 py-2 font-mono text-xs text-violet-200">
{setup.secret}
</code>
</div>
</div>
<div className="max-w-xs">
<label className="field-label" htmlFor="totp-code">
Code aus der App
</label>
<input
id="totp-code"
className="field text-center text-lg tracking-[0.35em]"
value={code}
onChange={(event) => setCode(event.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={7}
required
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
Aktivieren
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setSetup(null);
setCode("");
setNotice(null);
}}
>
Abbrechen
</button>
</div>
</form>
) : null}
{state.enabled && !setup ? (
<div className="space-y-3">
<p className="text-sm text-slate-400">
{state.recoveryCodesLeft > 0 ? (
<>
Noch <strong className="text-slate-200">{state.recoveryCodesLeft}</strong>{" "}
Wiederherstellungscodes übrig.
</>
) : (
<span className="text-amber-200">
Keine Wiederherstellungscodes mehr übrig. Ohne sie hilft bei einem verlorenen
Gerät nichts mehr — bitte neue erzeugen.
</span>
)}
</p>
{confirming ? (
<form onSubmit={submitPassword} className="max-w-sm space-y-3">
<div>
<label className="field-label" htmlFor="totp-password">
Passwort bestätigen
</label>
<input
id="totp-password"
className="field"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
autoFocus
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="submit"
className={cn("btn", confirming === "disable" ? "btn-danger" : "btn-primary")}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{confirming === "disable" ? "Abschalten" : "Neue Codes erzeugen"}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setConfirming(null);
setPassword("");
}}
>
Abbrechen
</button>
</div>
</form>
) : (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-ghost"
onClick={() => setConfirming("recovery")}
>
<RefreshCw className="h-4 w-4" />
Neue Wiederherstellungscodes
</button>
<button
type="button"
className="btn btn-ghost text-rose-200"
onClick={() => setConfirming("disable")}
>
<ShieldOff className="h-4 w-4" />
Abschalten
</button>
</div>
)}
</div>
) : 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>
);
}
/**
* Die Codes werden nur dieses eine Mal gezeigt. Deshalb Kopieren und
* Herunterladen direkt daneben – und ein Knopf, der das Wegklicken zu einer
* bewussten Handlung macht.
*/
function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) {
const [copied, setCopied] = useState(false);
const text = codes.join("\n");
async function copy() {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Ohne Zwischenablage-Recht bleibt Markieren von Hand.
}
}
function download() {
const url = URL.createObjectURL(new Blob([`${text}\n`], { type: "text/plain" }));
const link = document.createElement("a");
link.href = url;
link.download = "nebula-wiederherstellungscodes.txt";
link.click();
URL.revokeObjectURL(url);
}
return (
<div className="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/8 p-4">
<h3 className="text-sm font-semibold text-amber-100">Wiederherstellungscodes</h3>
<p className="mt-1 text-sm text-amber-200/80">
Jeder Code gilt einmal. Bewahre sie an einem sicheren Ort auf — sie sind der einzige
Weg zurück, wenn dein Gerät verloren geht. <strong>Sie werden nur jetzt angezeigt.</strong>
</p>
<ul className="mt-3 grid grid-cols-2 gap-1.5 font-mono text-sm text-amber-50 sm:grid-cols-2">
{codes.map((entry) => (
<li key={entry} className="rounded-md bg-black/25 px-2.5 py-1.5">
{entry}
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={copy} className="btn btn-ghost text-xs">
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
<button type="button" onClick={download} className="btn btn-ghost text-xs">
<Download className="h-3.5 w-3.5" />
Herunterladen
</button>
<button type="button" onClick={onDone} className="btn btn-ghost text-xs">
Ich habe sie gesichert
</button>
</div>
</div>
);
}
+126
View File
@@ -9,6 +9,7 @@ import {
Download, Download,
Fingerprint, Fingerprint,
FolderGit2, FolderGit2,
Hourglass,
Loader2, Loader2,
Lock, Lock,
Plus, Plus,
@@ -67,6 +68,12 @@ export interface AdminData {
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */ /** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
eligible: number; eligible: number;
}; };
stale: {
afterDays: number;
lastCheckAt: string | Date | null;
/** Wie viele Konten ueberhaupt auf Stillstand hoeren. */
listeners: number;
};
} }
type Notice = { ok: boolean; text: string } | null; type Notice = { ok: boolean; text: string } | null;
@@ -187,6 +194,7 @@ export function AdminManager({ data }: { data: AdminData }) {
</section> </section>
<AutoSyncSection data={data.autoSync} /> <AutoSyncSection data={data.autoSync} />
<StaleSection data={data.stale} />
{/* ── Instanz ─────────────────────────────────────── */} {/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5"> <section className="glass p-5">
@@ -574,6 +582,124 @@ function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
); );
} }
const STALE_OPTIONS = [14, 30, 60, 90, 180, 365];
function StaleSection({ data }: { data: AdminData["stale"] }) {
const router = useRouter();
const [days, setDays] = useState(data.afterDays);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function save(value: number) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staleAfterDays: value }),
});
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) {
setDays(data.afterDays);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
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-amber-500/12 text-amber-300">
<Hourglass className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Stillstand
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Ab wann ein Projekt als eingeschlafen gilt. Massstab ist der letzte Commit im
verknuepften Repository – das Aenderungsdatum taugt nicht, weil jeder Abgleich
es anfasst. Projekte ohne Repository bleiben aussen vor, ebenso fertige und
archivierte.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="stale-days">
Schwelle
</label>
<select
id="stale-days"
className="field"
value={days}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setDays(value);
void save(value);
}}
>
{STALE_OPTIONS.map((value) => (
<option key={value} value={value}>
nach {value} Tagen ohne Commit
</option>
))}
</select>
</div>
<dl className="grid grid-cols-2 gap-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Letzte Pruefung
</dt>
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
{data.lastCheckAt ? formatRelative(data.lastCheckAt) : "noch keine"}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Empfaenger
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.listeners}</dd>
</div>
</dl>
</div>
{data.listeners === 0 ? (
<p className="mt-3 text-[11px] text-slate-600">
Niemand hoert derzeit auf diesen Anlass – die Pruefung laeuft dann gar nicht
erst. Einzurichten unter „Mein Konto → Benachrichtigungen“.
</p>
) : (
<p className="mt-3 text-[11px] text-slate-600">
Die Pruefung laeuft hoechstens einmal am Tag. Je Projekt wird einmal gemeldet;
erneut erst, wenn danach wieder ein Commit kam und es wieder still wurde.
</p>
)}
{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({ function ModeCard({
active, active,
disabled, disabled,
+97 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startAuthentication } from "@simplewebauthn/browser"; import { startAuthentication } from "@simplewebauthn/browser";
import { Fingerprint, KeyRound, Loader2, LogIn } from "lucide-react"; import { Fingerprint, KeyRound, Loader2, LogIn, ShieldCheck } from "lucide-react";
export function LoginForm({ export function LoginForm({
passkeysAvailable, passkeysAvailable,
@@ -18,8 +18,11 @@ export function LoginForm({
const router = useRouter(); const router = useRouter();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [busy, setBusy] = useState<"password" | "passkey" | null>(null); const [busy, setBusy] = useState<"password" | "passkey" | "code" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Passwort stimmt, jetzt fehlt der zweite Faktor. */
const [mfa, setMfa] = useState(false);
const [code, setCode] = useState("");
function done() { function done() {
// replace statt push: die Anmeldeseite soll nicht im Verlauf landen. // replace statt push: die Anmeldeseite soll nicht im Verlauf landen.
@@ -39,6 +42,14 @@ export function LoginForm({
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen."); if (!response.ok) throw new Error(payload.error ?? "Anmeldung fehlgeschlagen.");
// Zweiter Faktor eingerichtet: das Passwort allein reicht nicht.
if (payload.mfaRequired) {
setMfa(true);
setPassword("");
setBusy(null);
return;
}
done(); done();
} catch (caught) { } catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught)); setError(caught instanceof Error ? caught.message : String(caught));
@@ -46,6 +57,26 @@ export function LoginForm({
} }
} }
async function submitCode(event: React.FormEvent) {
event.preventDefault();
setBusy("code");
setError(null);
try {
const response = await fetch("/api/auth/mfa", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Der Code stimmt nicht.");
done();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
setCode("");
setBusy(null);
}
}
async function loginWithPasskey() { async function loginWithPasskey() {
setBusy("passkey"); setBusy("passkey");
setError(null); setError(null);
@@ -82,6 +113,70 @@ export function LoginForm({
} }
} }
// Beim zweiten Schritt bleibt nur noch der Code stehen. Benutzername und
// Passwort daneben einzublenden wuerde nur zum erneuten Absenden verleiten.
if (mfa) {
return (
<form onSubmit={submitCode} className="space-y-4">
<div className="flex items-start gap-2 rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-sm text-slate-400">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-violet-300" />
<span>
Bitte den sechsstelligen Code aus deiner Authenticator-App eingeben. Kein
Zugriff darauf? Ein Wiederherstellungscode geht auch.
</span>
</div>
<div>
<label className="field-label" htmlFor="login-code">
Code
</label>
<input
id="login-code"
className="field text-center text-lg tracking-[0.35em]"
value={code}
onChange={(event) => setCode(event.target.value)}
// Nicht type="number": das schluckt fuehrende Nullen und die
// Bindestriche der Wiederherstellungscodes.
inputMode="text"
autoComplete="one-time-code"
autoCapitalize="none"
spellCheck={false}
maxLength={40}
required
autoFocus
/>
</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 === "code" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
Bestätigen
</button>
<button
type="button"
onClick={() => {
setMfa(false);
setCode("");
setError(null);
}}
className="w-full text-center text-sm text-slate-500 hover:text-slate-300"
>
Abbrechen
</button>
</form>
);
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{passkeysAvailable ? ( {passkeysAvailable ? (
+329
View File
@@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
pointerWithin,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragOverEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckSquare, Loader2, Plus } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { TaskCard } from "@/components/tasks/TaskCard";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn } from "@/lib/utils";
type Columns = Record<TaskStatus, Task[]>;
function group(tasks: Task[]): Columns {
const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns;
for (const task of [...tasks].sort((a, b) => a.position - b.position)) {
columns[task.status].push(task);
}
return columns;
}
/**
* Aufgabenbrett eines Projekts.
*
* Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte
* hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen.
* Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue
* Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist
* wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus.
*/
export function TaskBoard({
projectId,
tasks,
progressFromTasks,
}: {
projectId: string;
tasks: Task[];
progressFromTasks: boolean;
}) {
const router = useRouter();
const [columns, setColumns] = useState<Columns>(() => group(tasks));
const [activeId, setActiveId] = useState<string | null>(null);
const [editing, setEditing] = useState<Task | null>(null);
const [creatingIn, setCreatingIn] = useState<TaskStatus | null>(null);
const [saving, setSaving] = useState(false);
const dragging = useRef(false);
// Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst
// wuerde die Karte mitten in der Bewegung zurueckspringen.
useEffect(() => {
if (!dragging.current) setColumns(group(tasks));
}, [tasks]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
);
const allTasks = useMemo(() => Object.values(columns).flat(), [columns]);
const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null;
const total = allTasks.length;
const done = columns.DONE.length;
function containerOf(id: string): TaskStatus | null {
if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus;
for (const status of TASK_STATUS_ORDER) {
if (columns[status].some((task) => task.id === id)) return status;
}
return null;
}
function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id));
}
/** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((task) => task.id === activeKey);
if (!moved) return current;
const targetIndex = current[to].findIndex((task) => task.id === overKey);
const insertAt = targetIndex >= 0 ? targetIndex : current[to].length;
return {
...current,
[from]: current[from].filter((task) => task.id !== activeKey),
[to]: [
...current[to].slice(0, insertAt),
{ ...moved, status: to },
...current[to].slice(insertAt),
],
};
});
}
async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const overContainer = containerOf(overKey);
if (container && container === overContainer) {
const list = columns[container];
const oldIndex = list.findIndex((task) => task.id === activeKey);
const newIndex = list.findIndex((task) => task.id === overKey);
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
const reordered = [...list];
const [moved] = reordered.splice(oldIndex, 1);
reordered.splice(newIndex, 0, moved);
next = { ...columns, [container]: reordered };
setColumns(next);
}
}
}
setActiveId(null);
dragging.current = false;
await persist(next);
}
async function persist(next: Columns) {
setSaving(true);
try {
const payload = {
columns: Object.fromEntries(
TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]),
),
};
const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error("failed");
router.refresh();
} catch {
// Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
setColumns(group(tasks));
} finally {
setSaving(false);
}
}
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-violet-500/12 text-violet-300">
<CheckSquare className="h-4 w-4" />
</span>
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-200">
Aufgaben
{total > 0 ? (
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{done}/{total}
</span>
) : null}
{saving ? <Loader2 className="h-3 w-3 animate-spin text-slate-500" /> : null}
</h2>
<p className="mt-0.5 text-[11px] text-slate-500">
{progressFromTasks
? "Der Fortschritt des Projekts folgt diesen Aufgaben."
: "Ziehen zum Verschieben und Sortieren."}
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={() => setCreatingIn("TODO")}
>
<Plus className="h-3.5 w-3.5" />
Aufgabe
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}
onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(tasks));
}}
>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{TASK_STATUS_ORDER.map((status) => (
<TaskColumn
key={status}
status={status}
tasks={columns[status]}
onAdd={() => setCreatingIn(status)}
onEdit={setEditing}
/>
))}
</div>
<DragOverlay dropAnimation={{ duration: 180, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
{active ? <TaskCard task={active} overlay /> : null}
</DragOverlay>
</DndContext>
<TaskDialog
open={creatingIn !== null || editing !== null}
projectId={projectId}
task={editing}
initialStatus={creatingIn ?? "TODO"}
onClose={() => {
setCreatingIn(null);
setEditing(null);
}}
/>
</section>
);
}
function TaskColumn({
status,
tasks,
onAdd,
onEdit,
}: {
status: TaskStatus;
tasks: Task[];
onAdd: () => void;
onEdit: (task: Task) => void;
}) {
const meta = TASK_STATUS_META[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div
ref={setNodeRef}
data-task-column={status}
className={cn(
"flex min-h-[8rem] flex-col rounded-xl border border-white/6 bg-black/20 p-2 transition-colors",
isOver && "border-violet-400/40 bg-violet-500/6",
)}
>
<div className="mb-2 flex items-center gap-2 px-1">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">
{meta.label}
</h3>
<span className="ml-auto text-[10px] tabular-nums text-slate-600">{tasks.length}</span>
</div>
<SortableContext
items={tasks.map((task) => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-1 flex-col gap-1.5">
{tasks.map((task) => (
<SortableTask key={task.id} task={task} onEdit={onEdit} />
))}
{tasks.length === 0 ? (
<p className="rounded-lg border border-dashed border-white/8 px-2 py-4 text-center text-[10px] text-slate-600">
{isOver ? "Hier ablegen" : "leer"}
</p>
) : null}
</div>
</SortableContext>
<button
type="button"
onClick={onAdd}
className="mt-1.5 flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-white/5 hover:text-slate-300"
>
<Plus className="h-3 w-3" />
hinzufuegen
</button>
</div>
);
}
function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn(isDragging && "opacity-30")}
>
<TaskCard task={task} onEdit={onEdit} dragHandle={{ ...attributes, ...listeners }} />
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { AlignLeft, CalendarClock, GripVertical } from "lucide-react";
import type { Task } from "@prisma/client";
import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
function dueTone(due: Date | string | null, done: boolean) {
if (!due || done) return null;
const date = new Date(due);
if (Number.isNaN(date.getTime())) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const days = Math.round((target.getTime() - today.getTime()) / 86_400_000);
if (days < 0) return { className: "text-rose-300", label: "ueberfaellig" };
if (days === 0) return { className: "text-amber-300", label: "heute" };
if (days <= 3) return { className: "text-amber-200/70", label: null };
return { className: "text-slate-500", label: null };
}
export function TaskCard({
task,
onEdit,
dragHandle,
overlay = false,
}: {
task: Task;
onEdit?: (task: Task) => void;
dragHandle?: Record<string, unknown>;
overlay?: boolean;
}) {
const done = task.status === "DONE";
const due = dueTone(task.dueDate, done);
return (
<article
className={cn(
"group rounded-lg border border-white/8 bg-[#0b0d1c] p-2 transition-colors",
!overlay && "hover:border-white/16",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
)}
>
<div className="flex items-start gap-1.5">
<button
type="button"
className="mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-slate-700 transition-colors hover:text-slate-400 active:cursor-grabbing"
aria-label={`${task.title} verschieben`}
{...dragHandle}
>
<GripVertical className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => onEdit?.(task)}
disabled={!onEdit}
className="min-w-0 flex-1 text-left"
>
<p
className={cn(
"break-words text-xs leading-snug text-slate-200",
done && "text-slate-500 line-through",
)}
>
{task.title}
</p>
{(task.description || task.dueDate || task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null}
{task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" />
{due?.label ?? formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 3).map((label) => (
<span key={label} className="rounded bg-white/6 px-1 py-0.5 text-slate-400">
{label}
</span>
))}
</div>
)}
</button>
</div>
</article>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Task, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils";
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
function toDateInput(value: Date | string | null): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const offset = date.getTimezoneOffset() * 60_000;
return new Date(date.getTime() - offset).toISOString().slice(0, 10);
}
export function TaskDialog({
open,
projectId,
task,
initialStatus,
onClose,
}: {
open: boolean;
projectId: string;
task: Task | null;
initialStatus: TaskStatus;
onClose: () => void;
}) {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(task);
useEffect(() => {
if (!open) return;
setTitle(task?.title ?? "");
setDescription(task?.description ?? "");
setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? "");
setError(null);
}, [open, task, initialStatus]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
const body = {
title,
description,
status,
// Ohne Uhrzeit auf Mittag legen, damit Zeitzonenverschiebungen das
// Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels),
};
try {
const response = await fetch(
isEdit ? `/api/tasks/${task?.id}` : `/api/projects/${projectId}/tasks`,
{
method: isEdit ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
async function remove() {
if (!task) return;
if (!window.confirm(`Aufgabe „${task.title}“ loeschen?`)) return;
setBusy(true);
try {
const response = await fetch(`/api/tasks/${task.id}`, { method: "DELETE" });
if (!response.ok) throw new Error("Loeschen fehlgeschlagen.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title={isEdit ? "Aufgabe bearbeiten" : "Neue Aufgabe"}
description={isEdit ? undefined : "Titel genuegt – alles andere ist optional."}
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="task-title">
Titel *
</label>
<input
id="task-title"
className="field"
value={title}
onChange={(event) => setTitle(event.target.value)}
required
maxLength={200}
autoFocus
/>
</div>
<div>
<span className="field-label">Spalte</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{TASK_STATUS_ORDER.map((value) => {
const meta = TASK_STATUS_META[value];
return (
<button
key={value}
type="button"
onClick={() => setStatus(value)}
className={cn(
"rounded-lg border px-2 py-2 text-xs transition-all",
status === value
? "border-violet-400/60 bg-violet-500/12 text-white"
: "border-white/9 text-slate-400 hover:border-white/20",
)}
>
<span className="flex items-center justify-center gap-1.5">
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color }}
/>
{meta.label}
</span>
</button>
);
})}
</div>
</div>
<div>
<label className="field-label" htmlFor="task-description">
Beschreibung
</label>
<textarea
id="task-description"
className="field min-h-24 resize-y font-mono text-[13px]"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Markdown wird dargestellt."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="task-due">
Faellig am
</label>
<input
id="task-due"
className="field"
type="date"
value={dueDate}
onChange={(event) => setDueDate(event.target.value)}
/>
</div>
<div>
<label className="field-label" htmlFor="task-labels">
Labels (kommagetrennt)
</label>
<input
id="task-labels"
className="field"
value={labels}
onChange={(event) => setLabels(event.target.value)}
placeholder="bug, ui"
/>
</div>
</div>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex items-center justify-between gap-2 pt-1">
{isEdit ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Loeschen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={busy || !title.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEdit ? "Speichern" : "Anlegen"}
</button>
</div>
</div>
</form>
</Modal>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { describeFields, describeStatus, shorten } from "./activity";
describe("describeFields", () => {
it("nennt ein einzelnes Feld beim Namen", () => {
expect(describeFields(["name"])).toBe("Name geändert");
});
it("zaehlt zwei und drei Felder auf", () => {
expect(describeFields(["name", "tags"])).toBe("Name und Tags geändert");
expect(describeFields(["name", "tags", "priority"])).toBe(
"Name, Tags und Priorität geändert",
);
});
it("fasst ab vier Feldern zusammen, statt eine Litanei zu bilden", () => {
expect(describeFields(["name", "tags", "priority", "accent"])).toBe("4 Felder geändert");
});
it("zaehlt zusammengehoerende Felder nur einmal", () => {
// repoOwner und repoName sind fuer den Leser eine einzige Aenderung.
expect(describeFields(["repoOwner", "repoName"])).toBe("Repository geändert");
});
it("liefert null, wenn nichts Nennenswertes dabei war", () => {
// slug wird nur mitgeschrieben, wenn sich der Name aendert – der Name
// steht dann ohnehin schon in der Liste.
expect(describeFields([])).toBeNull();
expect(describeFields(["slug", "updatedAt"])).toBeNull();
});
});
describe("describeStatus", () => {
it("uebersetzt beide Seiten in die Anzeigenamen", () => {
expect(describeStatus("IDEA", "IN_DEVELOPMENT")).toBe("Status: Idee → In Entwicklung");
});
it("laesst unbekannte Werte stehen, statt sie zu verschlucken", () => {
expect(describeStatus("IDEA", "PHANTASIE")).toBe("Status: Idee → PHANTASIE");
});
});
describe("shorten", () => {
it("laesst Kurzes unangetastet", () => {
expect(shorten("Kurzer Titel")).toBe("Kurzer Titel");
});
it("kuerzt auf die vorgegebene Laenge einschliesslich Auslassungszeichen", () => {
const gekuerzt = shorten("x".repeat(100), 20);
expect(gekuerzt).toHaveLength(20);
expect(gekuerzt.endsWith("…")).toBe(true);
});
it("macht aus Zeilenumbruechen eine Zeile", () => {
expect(shorten("Erste Zeile\n\n zweite Zeile ")).toBe("Erste Zeile zweite Zeile");
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { ActivityKind, Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { STATUS_META } from "@/lib/status";
/**
* Verlauf je Projekt: was ist wann passiert.
*
* Der Einzeiler wird beim Schreiben formuliert und nicht erst beim Anzeigen.
* Das ist Absicht: ein Eintrag soll auch dann noch lesbar sein, wenn es die
* Notiz oder die Aufgabe, um die es ging, laengst nicht mehr gibt.
*/
/** Wie viele Eintraege je Projekt aufgehoben werden. */
export const ACTIVITY_LIMIT = 200;
type Entry = {
projectId: string;
/** null = die Anwendung selbst, etwa der Zeitgeber beim Abgleich. */
userId?: string | null;
kind: ActivityKind;
summary: string;
meta?: Prisma.InputJsonValue;
};
/**
* Haelt einen Vorgang fest. Wirft nie: der Verlauf ist Beiwerk, und daran
* darf die eigentliche Aenderung nicht scheitern.
*/
export async function recordActivity(entry: Entry): Promise<void> {
try {
await prisma.activity.create({
data: {
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
},
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
/** Mehrere Vorgaenge auf einmal – ein Rundgang statt vieler. */
export async function recordActivities(entries: Entry[]): Promise<void> {
if (entries.length === 0) return;
try {
await prisma.activity.createMany({
data: entries.map((entry) => ({
projectId: entry.projectId,
userId: entry.userId ?? null,
kind: entry.kind,
summary: entry.summary,
meta: entry.meta,
})),
});
} catch (error) {
console.error("[activity] konnte nicht geschrieben werden:", error);
}
}
// ── Formulierungshilfen ──────────────────────────────────────
const FIELD_LABELS: Record<string, string> = {
name: "Name",
summary: "Kurzbeschreibung",
description: "Beschreibung",
priority: "Priorität",
progress: "Fortschritt",
accent: "Farbe",
tags: "Tags",
favorite: "Favorit",
autoSync: "Automatischer Abgleich",
progressFromTasks: "Fortschritt aus Aufgaben",
connectionId: "Git-Verbindung",
repoUrl: "Repository",
repoOwner: "Repository",
repoName: "Repository",
defaultBranch: "Standard-Branch",
};
/**
* Aus geaenderten Feldnamen einen lesbaren Satz machen: "Name und Tags
* geändert". Doppelte Bezeichnungen fallen weg – wer repoOwner und repoName
* zusammen setzt, hat einmal das Repository geaendert, nicht zweimal.
*/
export function describeFields(fields: string[]): string | null {
const labels: string[] = [];
for (const field of fields) {
const label = FIELD_LABELS[field];
if (label && !labels.includes(label)) labels.push(label);
}
if (labels.length === 0) return null;
if (labels.length === 1) return `${labels[0]} geändert`;
// Ab vier Feldern wird die Aufzaehlung laenger als sie nuetzt.
if (labels.length > 3) return `${labels.length} Felder geändert`;
return `${labels.slice(0, -1).join(", ")} und ${labels.at(-1)} geändert`;
}
/** "Idee → In Entwicklung" */
export function describeStatus(from: string, to: string): string {
const label = (value: string) =>
STATUS_META[value as keyof typeof STATUS_META]?.label ?? value;
return `Status: ${label(from)} → ${label(to)}`;
}
/** Kuerzt lange Titel, damit ein Eintrag eine Zeile bleibt. */
export function shorten(text: string, max = 70): string {
const clean = text.replace(/\s+/g, " ").trim();
return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`;
}
/**
* Stutzt den Verlauf auf die neuesten Eintraege je Projekt zurueck. Laeuft
* im taeglichen Durchgang des Zeitgebers mit – bei jedem Schreiben zu
* pruefen waere eine zusaetzliche Abfrage fuer nichts.
*/
export async function pruneActivities(): Promise<number> {
const rows = await prisma.$queryRaw<{ count: bigint }[]>`
WITH nummeriert AS (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "projectId" ORDER BY "createdAt" DESC, "id" DESC
) AS rang
FROM "Activity"
), geloescht AS (
DELETE FROM "Activity"
WHERE "id" IN (SELECT "id" FROM nummeriert WHERE rang > ${ACTIVITY_LIMIT})
RETURNING 1
)
SELECT COUNT(*)::bigint AS count FROM geloescht
`;
return Number(rows[0]?.count ?? 0);
}
+128
View File
@@ -0,0 +1,128 @@
import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextResponse } from "next/server";
import { COOKIES_SECURE, MFA_COOKIE, MFA_TTL_MS } from "@/lib/config";
import { tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
/**
* Der Zwischenzustand zwischen Passwort und zweitem Faktor.
*
* Bewusst nicht als halbfertige Sitzung: eine Sitzungszeile mit einem
* "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen
* Filter irgendwo doch als Anmeldung durchgeht. Ein eigenes Modell kann
* dieses Versehen nicht passieren.
*/
/** Nach so vielen Fehlversuchen ist der Vorgang verbrannt. */
export const MFA_MAX_ATTEMPTS = 5;
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("base64url");
}
export async function createPendingLogin(userId: string): Promise<string> {
const token = randomBytes(32).toString("base64url");
// Aeltere Vorgaenge desselben Kontos verwerfen: es kann nur einen geben,
// sonst sammeln sich bei jedem Anlauf neue Rateversuche an.
await prisma.pendingLogin.deleteMany({
where: { OR: [{ userId }, { expiresAt: { lt: new Date() } }] },
});
await prisma.pendingLogin.create({
data: {
userId,
tokenHash: hashToken(token),
expiresAt: new Date(Date.now() + MFA_TTL_MS),
},
});
return token;
}
export function setMfaCookie(response: NextResponse, token: string) {
response.cookies.set({
name: MFA_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: Math.floor(MFA_TTL_MS / 1000),
});
}
export function clearMfaCookie(response: NextResponse) {
response.cookies.set({
name: MFA_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
});
}
export interface PendingLoginState {
id: string;
attempts: number;
user: {
id: string;
username: string;
role: "ADMIN" | "USER";
isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null;
};
}
/** Liest den laufenden Vorgang aus dem Cookie. */
export async function readPendingLogin(): Promise<PendingLoginState | null> {
const store = await cookies();
const token = store.get(MFA_COOKIE)?.value;
if (!token) return null;
const pending = await prisma.pendingLogin.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!pending) return null;
if (pending.expiresAt.getTime() <= Date.now()) {
await prisma.pendingLogin.delete({ where: { id: pending.id } }).catch(() => undefined);
return null;
}
return {
id: pending.id,
attempts: pending.attempts,
user: {
id: pending.user.id,
username: pending.user.username,
role: pending.user.role,
isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
},
};
}
/** Zaehlt einen Fehlversuch. Gibt zurueck, ob noch Versuche offen sind. */
export async function noteMfaFailure(id: string): Promise<boolean> {
const updated = await prisma.pendingLogin.update({
where: { id },
data: { attempts: { increment: 1 } },
select: { attempts: true },
});
if (updated.attempts >= MFA_MAX_ATTEMPTS) {
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
return false;
}
return true;
}
export async function discardPendingLogin(id: string): Promise<void> {
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("ein-langes-testpasswort", hash)).resolves.toBe(true);
});
it("weist ein falsches Passwort ab", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
await expect(verifyPassword("etwas-anderes", hash)).resolves.toBe(false);
});
it("erzeugt fuer dasselbe Passwort unterschiedliche Hashes", async () => {
// Unterschiedliches Salz – sonst waere an gleichen Hashes ablesbar,
// wer dasselbe Passwort benutzt.
const a = await hashPassword("ein-langes-testpasswort");
const b = await hashPassword("ein-langes-testpasswort");
expect(a).not.toBe(b);
});
it("haelt das erwartete Ablageformat ein", async () => {
const hash = await hashPassword("ein-langes-testpasswort");
expect(hash).toMatch(/^scrypt\$\d+\$\d+\$\d+\$[\w-]+\$[\w-]+$/);
});
it("stuerzt bei kaputten oder fehlenden Hashes nicht ab", async () => {
await expect(verifyPassword("x", null)).resolves.toBe(false);
await expect(verifyPassword("x", "")).resolves.toBe(false);
await expect(verifyPassword("x", "voelliger-unsinn")).resolves.toBe(false);
await expect(verifyPassword("x", "scrypt$a$b$c$d$e")).resolves.toBe(false);
});
it("behandelt gleichwertige Unicode-Schreibweisen gleich", async () => {
// "é" als ein Zeichen und als e + Akzent kombiniert.
const hash = await hashPassword("passwort-café-lang");
await expect(verifyPassword("passwort-café-lang", hash)).resolves.toBe(true);
});
});
describe("validatePasswordStrength", () => {
it("nimmt ein ausreichend langes Passwort an", () => {
expect(validatePasswordStrength("ein-langes-testpasswort")).toBeNull();
});
it("weist zu kurze Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(PASSWORD_MIN_LENGTH - 1))).toMatch(
/mindestens/,
);
});
it("weist naheliegende Passwoerter ab", () => {
expect(validatePasswordStrength("password")).toBeTruthy();
expect(validatePasswordStrength("Passwort")).toBeTruthy();
});
it("weist reine Ziffernfolgen ab", () => {
expect(validatePasswordStrength("1234567890123")).toMatch(/Ziffern/);
});
it("weist uebermaessig lange Passwoerter ab", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { hashCode, normalizeCode } from "./recovery";
describe("normalizeCode", () => {
it("setzt den Bindestrich, wenn er beim Abtippen fehlt", () => {
expect(normalizeCode("a7k2mp9xqd")).toBe("a7k2m-p9xqd");
});
it("nimmt Grossschreibung und Leerzeichen hin", () => {
// Aus einem Ausdruck abgeschrieben sieht ein Code selten so aus wie
// beim Erzeugen.
expect(normalizeCode("A7K2M-P9XQD")).toBe("a7k2m-p9xqd");
expect(normalizeCode(" a7k2m p9xqd ")).toBe("a7k2m-p9xqd");
expect(normalizeCode("a7k2m—p9xqd")).toBe("a7k2m-p9xqd");
});
it("laesst zu kurze Eingaben unveraendert, statt sie zurechtzubiegen", () => {
expect(normalizeCode("abc")).toBe("abc");
});
});
describe("hashCode", () => {
it("bildet Schreibweisen desselben Codes auf denselben Hash ab", () => {
expect(hashCode("A7K2M-P9XQD")).toBe(hashCode("a7k2mp9xqd"));
});
it("unterscheidet verschiedene Codes", () => {
expect(hashCode("a7k2m-p9xqd")).not.toBe(hashCode("a7k2m-p9xqe"));
});
it("gibt den Code nicht preis", () => {
expect(hashCode("a7k2m-p9xqd")).not.toContain("a7k2m");
});
});
+100
View File
@@ -0,0 +1,100 @@
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Wiederherstellungscodes fuer den Fall, dass der Authenticator verloren geht.
*
* Ohne sie waere die Zwei-Faktor-Anmeldung eine Falle: ein neues Handy, und
* das Konto ist zu. Ein Administrator kann zwar aushelfen – im
* Einzelbenutzerbetrieb ist der Administrator aber genau die ausgesperrte
* Person.
*/
/** Wie viele Codes bei der Einrichtung erzeugt werden. */
export const RECOVERY_CODE_COUNT = 10;
/**
* Alphabet ohne die Paare, die man beim Abschreiben verwechselt: kein O/0,
* kein I/l/1, kein B/8. Die Codes werden von Hand aus einem Ausdruck oder
* Passwortmanager uebertragen, oft unter Zeitdruck.
*/
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
const GROUP = 5;
/** Ein Code der Form "a7k2m-p9xqd" – rund 50 Bit Zufall. */
function generateCode(): string {
const zeichen = Array.from(
{ length: GROUP * 2 },
() => ALPHABET[randomInt(ALPHABET.length)],
).join("");
return `${zeichen.slice(0, GROUP)}-${zeichen.slice(GROUP)}`;
}
/** Vereinheitlicht Eingaben: Grossschreibung und fehlender Bindestrich. */
export function normalizeCode(input: string): string {
const clean = input.toLowerCase().replace(/[^a-z0-9]/g, "");
if (clean.length !== GROUP * 2) return clean;
return `${clean.slice(0, GROUP)}-${clean.slice(GROUP)}`;
}
export function hashCode(code: string): string {
return createHash("sha256").update(normalizeCode(code)).digest("base64url");
}
/**
* Erzeugt einen frischen Satz und verwirft den alten. Gibt die Codes im
* Klartext zurueck – das ist die einzige Gelegenheit, sie zu sehen.
*/
export async function issueRecoveryCodes(userId: string): Promise<string[]> {
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateCode);
await prisma.$transaction([
prisma.recoveryCode.deleteMany({ where: { userId } }),
prisma.recoveryCode.createMany({
data: codes.map((code) => ({ userId, codeHash: hashCode(code) })),
}),
]);
return codes;
}
/**
* Loest einen Code ein. Ein Code gilt genau einmal; verbraucht wird er auch
* dann, wenn die Anmeldung danach aus anderem Grund scheitert – lieber ein
* Code zu viel verbraucht als einer, der zweimal geht.
*/
export async function redeemRecoveryCode(userId: string, input: string): Promise<boolean> {
const eingabe = normalizeCode(input);
if (eingabe.length < GROUP * 2) return false;
const offen = await prisma.recoveryCode.findMany({
where: { userId, usedAt: null },
select: { id: true, codeHash: true },
});
const gesucht = Buffer.from(hashCode(eingabe), "utf8");
let treffer: string | null = null;
for (const code of offen) {
const vorhanden = Buffer.from(code.codeHash, "utf8");
// Nicht abbrechen, sobald es passt: die Laufzeit soll nicht verraten,
// an welcher Stelle der Liste der Code stand.
if (vorhanden.length === gesucht.length && timingSafeEqual(vorhanden, gesucht)) {
treffer = code.id;
}
}
if (!treffer) return false;
// updateMany mit der Bedingung "noch unbenutzt": zwei gleichzeitige
// Versuche mit demselben Code koennen so nicht beide durchkommen.
const result = await prisma.recoveryCode.updateMany({
where: { id: treffer, usedAt: null },
data: { usedAt: new Date() },
});
return result.count === 1;
}
/** Wie viele Codes noch ungenutzt sind – fuer den Hinweis im Konto. */
export async function countUnusedRecoveryCodes(userId: string): Promise<number> {
return prisma.recoveryCode.count({ where: { userId, usedAt: null } });
}
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
TOTP_PERIOD,
base32Decode,
base32Encode,
counterFor,
generateTotpSecret,
groupSecret,
otpauthUri,
totpCode,
verifyTotp,
} from "./totp";
describe("Base32", () => {
// Testvektoren aus RFC 4648, Abschnitt 10 – ohne die Auffuellzeichen,
// die Authenticator-Apps nicht erwarten.
it.each([
["", ""],
["f", "MY"],
["fo", "MZXQ"],
["foo", "MZXW6"],
["foob", "MZXW6YQ"],
["fooba", "MZXW6YTB"],
["foobar", "MZXW6YTBOI"],
])("kodiert %j als %j", (klartext, erwartet) => {
expect(base32Encode(Buffer.from(klartext, "utf8"))).toBe(erwartet);
});
it("dekodiert zurueck, auch mit Leerzeichen und Auffuellzeichen", () => {
expect(base32Decode("MZXW6YTBOI").toString("utf8")).toBe("foobar");
expect(base32Decode("mzxw 6ytb oi").toString("utf8")).toBe("foobar");
expect(base32Decode("MZXW6YTBOI======").toString("utf8")).toBe("foobar");
});
it("weist fremde Zeichen ab, statt still Unsinn zu liefern", () => {
// 0, 1 und 8 gehoeren nicht zum Alphabet – gerade sie werden aber gern
// mit O, I und B verwechselt.
expect(() => base32Decode("MZXW0YTB")).toThrow();
});
});
describe("TOTP nach RFC 6238", () => {
// Der Schluessel aus Anhang B des RFC: die ASCII-Ziffern 1234567890,
// zweimal hintereinander.
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
/**
* Der RFC nennt achtstellige Codes. Sechs Stellen sind davon die letzten
* sechs – gerechnet wird modulo 10^6 statt 10^8.
*/
it.each([
[59, "287082"],
[1111111109, "081804"],
[1111111111, "050471"],
[1234567890, "005924"],
[2000000000, "279037"],
[20000000000, "353130"],
])("liefert zum Zeitpunkt %i den Code %s", (sekunden, erwartet) => {
const counter = Math.floor(sekunden / TOTP_PERIOD);
expect(totpCode(SECRET, counter)).toBe(erwartet);
});
it("rechnet Zeitpunkte in Fenster um", () => {
expect(counterFor(new Date(59_000))).toBe(1);
expect(counterFor(new Date(1111111109_000))).toBe(37037036);
});
});
describe("verifyTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
it("nimmt den Code des aktuellen Fensters an", () => {
expect(verifyTotp(SECRET, "081804", JETZT)).toBe(true);
});
it("nimmt auch das vorige und das naechste Fenster an", () => {
// Eine um bis zu 30 Sekunden falsch gehende Uhr soll niemanden aussperren.
const vorher = new Date(JETZT.getTime() - TOTP_PERIOD * 1000);
const nachher = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", vorher)).toBe(true);
expect(verifyTotp(SECRET, "081804", nachher)).toBe(true);
});
it("lehnt ein Fenster weiter ab", () => {
const zuFrueh = new Date(JETZT.getTime() - 2 * TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", zuFrueh)).toBe(false);
});
it("lehnt falsche Codes und Unfug ab", () => {
expect(verifyTotp(SECRET, "000000", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "81804", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "abcdef", JETZT)).toBe(false);
// Fuehrende Nullen duerfen nicht als Zahl verlorengehen.
expect(verifyTotp(SECRET, "81804 ", JETZT)).toBe(false);
});
it("sieht ueber Leerzeichen in der Eingabe hinweg", () => {
expect(verifyTotp(SECRET, "081 804", JETZT)).toBe(true);
});
});
describe("Einrichtung", () => {
it("erzeugt Schluessel, die sich unterscheiden und lang genug sind", () => {
const a = generateTotpSecret();
const b = generateTotpSecret();
expect(a).not.toBe(b);
// 20 Byte ergeben 32 Base32-Zeichen.
expect(a).toHaveLength(32);
expect(base32Decode(a)).toHaveLength(20);
});
it("baut eine otpauth-URI mit Aussteller im Label und im Parameter", () => {
const uri = otpauthUri({ secret: "ABCD", account: "chefin", issuer: "Nebula" });
expect(uri).toContain("otpauth://totp/Nebula%3Achefin?");
expect(uri).toContain("secret=ABCD");
expect(uri).toContain("issuer=Nebula");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
});
it("gruppiert den Schluessel zum Abtippen", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
});
});
+158
View File
@@ -0,0 +1,158 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Zeitbasierte Einmalkennwoerter nach RFC 6238 (TOTP über HMAC-SHA1).
*
* Selbst gerechnet statt als Abhaengigkeit: es sind wenige Zeilen, das
* Verfahren steht seit 2011 fest, und der RFC bringt Testvektoren mit –
* die Richtigkeit laesst sich also beweisen statt nur behaupten
* (siehe totp.test.ts).
*
* SHA-1 ist hier kein Mangel. Es geht nicht um Kollisionsresistenz, sondern
* um HMAC mit einem geheimen Schluessel, und jede Authenticator-App der Welt
* erwartet genau diese Voreinstellung.
*/
/** Stellenzahl des Codes. */
export const TOTP_DIGITS = 6;
/** Laenge eines Zeitfensters in Sekunden. */
export const TOTP_PERIOD = 30;
/**
* Wie viele Fenster in jede Richtung zusaetzlich gelten. Eines deckt eine um
* bis zu 30 Sekunden falsch gehende Uhr ab – mehr wuerde den Zeitraum
* unnoetig verbreitern, in dem ein abgefangener Code noch taugt.
*/
export const TOTP_WINDOW = 1;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** Base32 nach RFC 4648, ohne Auffuellzeichen – so erwarten es die Apps. */
export function base32Encode(data: Buffer): string {
let bits = 0;
let value = 0;
let output = "";
for (const byte of data) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input: string): Buffer {
// Leerzeichen und Auffuellzeichen grosszuegig hinnehmen: Benutzer tippen
// den Schluessel gelegentlich aus einer Anzeige in Vierergruppen ab.
const clean = input.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, "");
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of clean) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) throw new Error("Ungueltiges Zeichen im Base32-Schluessel");
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/**
* Neuer Schluessel. 20 Byte entsprechen der Empfehlung des RFC fuer
* HMAC-SHA1 und ergeben 32 Base32-Zeichen.
*/
export function generateTotpSecret(): string {
return base32Encode(randomBytes(20));
}
/** Der Code fuer ein bestimmtes Zeitfenster. */
export function totpCode(secret: string, counter: number): string {
const key = base32Decode(secret);
// Zaehler als 8-Byte-Zahl in Big-Endian. BigInt, weil er theoretisch
// ueber 2^32 hinausgeht – praktisch erst in einigen tausend Jahren, aber
// die Bitschieberei mit Zahlen waere hier ohnehin heikel.
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", key).update(buffer).digest();
// Dynamic Truncation nach RFC 4226, Abschnitt 5.3.
const offset = digest[digest.length - 1] & 0x0f;
const binary =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
}
/** Das Zeitfenster, in dem ein Zeitpunkt liegt. */
export function counterFor(at: Date = new Date()): number {
return Math.floor(at.getTime() / 1000 / TOTP_PERIOD);
}
/**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert.
*/
export function verifyTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): boolean {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
}
function constantEquals(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
/**
* Die URI, die als QR-Code angezeigt wird. Das Label enthaelt sowohl den
* Aussteller als auch das Konto – so schreiben es die Authenticator-Apps
* vor, sonst steht in der Liste nur ein nackter Benutzername.
*/
export function otpauthUri(options: {
secret: string;
account: string;
issuer: string;
}): string {
const label = `${options.issuer}:${options.account}`;
const query = new URLSearchParams({
secret: options.secret,
issuer: options.issuer,
algorithm: "SHA1",
digits: String(TOTP_DIGITS),
period: String(TOTP_PERIOD),
});
return `otpauth://totp/${encodeURIComponent(label)}?${query.toString()}`;
}
/** Der Schluessel in Vierergruppen – zum Abtippen, wenn die Kamera streikt. */
export function groupSecret(secret: string): string {
return secret.replace(/(.{4})/g, "$1 ").trim();
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { CHANGELOG, KIND_META } from "./changelog";
import { APP_VERSION } from "./config";
/**
* Der Verlauf und die Versionsnummer sind zwei Angaben, die zusammenpassen
* muessen. Genau daran ist dieses Projekt schon einmal gescheitert: die
* Version stand in der Fusszeile und in der package.json und zeigte
* verschiedene Werte.
*/
describe("Aenderungsverlauf", () => {
it("beginnt mit der Fassung, die auch installiert ist", () => {
expect(CHANGELOG[0].version).toBe(APP_VERSION);
});
it("nennt jede Fassung nur einmal", () => {
const versionen = CHANGELOG.map((release) => release.version);
expect(new Set(versionen).size).toBe(versionen.length);
});
it("zaehlt absteigend", () => {
const zahl = (version: string) =>
version.split(".").reduce((sum, teil) => sum * 1000 + Number(teil), 0);
for (let index = 1; index < CHANGELOG.length; index += 1) {
expect(
zahl(CHANGELOG[index - 1].version),
`${CHANGELOG[index - 1].version} muss ueber ${CHANGELOG[index].version} stehen`,
).toBeGreaterThan(zahl(CHANGELOG[index].version));
}
});
it("hat zu jeder Fassung ein Datum, eine Zeile und mindestens eine Aenderung", () => {
for (const release of CHANGELOG) {
expect(release.version, "Version im Format x.y.z").toMatch(/^\d+\.\d+\.\d+$/);
expect(Number.isNaN(Date.parse(release.date)), `Datum von ${release.version}`).toBe(false);
expect(release.headline.length, `Zeile zu ${release.version}`).toBeGreaterThan(0);
expect(release.changes.length, `Eintraege zu ${release.version}`).toBeGreaterThan(0);
}
});
it("kennt zu jeder Art eine Beschriftung", () => {
for (const release of CHANGELOG) {
for (const change of release.changes) {
expect(KIND_META[change.kind], `${change.kind} in v${release.version}`).toBeDefined();
expect(change.text.trim().length).toBeGreaterThan(0);
}
}
});
});
+307
View File
@@ -0,0 +1,307 @@
/**
* Was sich von Fassung zu Fassung geaendert hat.
*
* Bewusst hier und nicht als CHANGELOG.md: die Liste wird in der Anwendung
* angezeigt, und zwei Quellen liefen prompt auseinander – wie es der
* Versionsnummer schon einmal passiert ist, die eine Zeit lang in der
* Fusszeile und in der package.json stand und verschiedene Werte zeigte.
*
* Die oberste Fassung ist die aktuelle. Neue Eintraege kommen nach vorne.
*/
export type ChangeKind = "neu" | "besser" | "behoben";
export interface ChangeEntry {
kind: ChangeKind;
text: string;
}
export interface Release {
version: string;
/** ISO-Datum, ohne Uhrzeit. */
date: string;
/** Ein Satz darueber, worum es in dieser Fassung ging. */
headline: string;
changes: ChangeEntry[];
}
export const KIND_META: Record<ChangeKind, { label: string; className: string }> = {
neu: {
label: "Neu",
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
},
besser: {
label: "Besser",
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
},
behoben: {
label: "Behoben",
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
},
};
export const CHANGELOG: Release[] = [
{
version: "1.1.0",
date: "2026-07-26",
headline: "Das Brett wird zur Arbeitsfläche.",
changes: [
{
kind: "neu",
text:
"Freie Reihenfolge im Brett: Karten lassen sich innerhalb einer Spalte anordnen, " +
"die Position bleibt erhalten.",
},
{
kind: "neu",
text:
"Mehrfachauswahl. Über die Kästchen mehrere Projekte wählen und Status, Tags oder " +
"Favoriten in einem Zug ändern — oder alle auf einmal löschen.",
},
{
kind: "neu",
text:
"Als App installierbar: Nebula bringt ein Web-Manifest und eigene Symbole mit und " +
"lässt sich auf dem Handy zum Startbildschirm hinzufügen.",
},
{
kind: "neu",
text: "Dieser Änderungsverlauf, erreichbar über die Versionsnummer in der Fußzeile.",
},
{
kind: "besser",
text:
"Ansicht, Sortierung und der Archivschalter überdauern das Neuladen. Wer sein " +
"Brett von Hand anordnet, will es beim nächsten Aufruf auch sehen.",
},
{
kind: "behoben",
text:
"Neu angelegte, importierte und verschobene Projekte bekamen keine Position und " +
"stapelten sich im Brett alle auf demselben Platz.",
},
{
kind: "besser",
text:
"Umsortieren im Brett fasst das Änderungsdatum nicht mehr an. Vorher schob ein " +
"Aufräumen sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.",
},
{
kind: "besser",
text:
"Die Sortierung im Kopf ist im Brett abgeschaltet — dort zählt die Anordnung von Hand. " +
"Sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.",
},
{
kind: "besser",
text:
"Prisma liest seine Einstellungen aus prisma.config.ts statt aus der package.json. " +
"Damit ist die Warnung vor Prisma 7 weg.",
},
],
},
{
version: "1.0.0",
date: "2026-07-26",
headline: "Der Funktionsumfang ist beisammen.",
changes: [
{
kind: "neu",
text:
"Aktivitätsverlauf je Projekt: Statuswechsel, Notizen, Aufgaben und neue Commits " +
"in einer Zeitleiste.",
},
{
kind: "neu",
text:
"Zwei-Faktor-Anmeldung per TOTP, mit QR-Code zum Einrichten und zehn " +
"Wiederherstellungscodes für den Fall, dass das Gerät verloren geht.",
},
{
kind: "neu",
text:
"Mehrere Repositories je Projekt — Frontend und Backend gehören in eine Karte. " +
"Jedes mit eigenen Kennzahlen, eigener CI und eigenem Abgleich.",
},
{
kind: "besser",
text:
"Benachrichtigungen merken sich den gemeldeten Zustand je Repository. Vorher " +
"verdeckte ein rotes Frontend die rote Pipeline des Backends.",
},
{
kind: "besser",
text:
"Stillstand bemisst sich am jüngsten Commit über alle Repositories: an einem " +
"Vorhaben wird gearbeitet, sobald an einem seiner Teile gearbeitet wird.",
},
{
kind: "behoben",
text:
"Der Verlauf lieferte ohne ausdrückliche Angabe genau einen Eintrag statt dreißig " +
"— Number(null) ist 0, nicht „nicht angegeben“.",
},
{
kind: "behoben",
text:
"Die Issue-Übernahme antwortete mit „nicht gefunden“, obwohl gar kein bestimmtes " +
"Repository angefragt war.",
},
],
},
{
version: "0.9.0",
date: "2026-07-26",
headline: "Schneller ans Ziel.",
changes: [
{
kind: "neu",
text:
"Schnellsuche mit Strg+K: Projekte, Notizen und Bereiche, bedienbar allein mit " +
"der Tastatur.",
},
{
kind: "neu",
text:
"Offene Git-Issues als Aufgaben übernehmen, mit Titel, Labels und Link zurück. " +
"Bereits übernommene werden übersprungen.",
},
],
},
{
version: "0.8.0",
date: "2026-07-26",
headline: "Der sanfte Stups.",
changes: [
{
kind: "neu",
text:
"Stillstehende Projekte melden sich, wenn länger kein Commit kam. Die Schwelle " +
"steht im Adminbereich.",
},
{
kind: "behoben",
text:
"Der neue Anlass fehlte im Prüfschema — die Oberfläche bot ihn an, der Server " +
"lehnte ihn ab. Ein Test hält beide Listen seitdem zusammen.",
},
],
},
{
version: "0.7.0",
date: "2026-07-26",
headline: "Bescheid bekommen, ohne nachzusehen.",
changes: [
{
kind: "neu",
text:
"Benachrichtigungen über ntfy, Webhook (HMAC-signiert) und E-Mail — bei " +
"fehlgeschlagenem Abgleich, roter CI oder neuer Version.",
},
{
kind: "besser",
text:
"Gemeldet wird nur bei einer Änderung. Sonst käme bei einem Repo mit abgelaufenem " +
"Token mit jedem Durchlauf dieselbe Nachricht.",
},
{
kind: "behoben",
text: "Im ntfy-Betreff wurden Emojis zu Fragezeichen, weil die Kopfzeile nur Latin-1 kann.",
},
],
},
{
version: "0.6.0",
date: "2026-07-26",
headline: "Prüfen statt behaupten.",
changes: [
{
kind: "neu",
text: "Ende-zu-Ende-Tests mit Playwright gegen die gebaute Anwendung und eine eigene Datenbank.",
},
{ kind: "neu", text: "CI-Pipeline für Gitea Actions." },
],
},
{
version: "0.5.0",
date: "2026-07-26",
headline: "Aufgaben je Projekt.",
changes: [
{
kind: "neu",
text: "Aufgabenbrett mit sortierbaren Spalten, Fälligkeitsdatum und Labels.",
},
{ kind: "neu", text: "Fortschritt wahlweise aus dem Verhältnis erledigter Aufgaben." },
],
},
{
version: "0.4.0",
date: "2026-07-26",
headline: "Ziehen und ablegen.",
changes: [
{ kind: "neu", text: "Kanban-Ansicht: Status per Ziehen ändern." },
{ kind: "neu", text: "CI-Status je Branch und Commit-Aktivität als Verlaufslinie." },
{
kind: "behoben",
text:
"Die Verlaufslinie log: bei 100 Commits innerhalb weniger Tage zeigte sie für die " +
"übrigen Tage Nullen, obwohl schlicht keine Daten vorlagen.",
},
],
},
{
version: "0.3.0",
date: "2026-07-26",
headline: "Text und Suche.",
changes: [
{ kind: "neu", text: "Markdown in Beschreibung und Notizen." },
{ kind: "neu", text: "Volltextsuche über Notizen, mit hervorgehobenen Fundstellen." },
{ kind: "neu", text: "Listen offener Pull Requests und Issues." },
{ kind: "behoben", text: "Die Versionsnummer stand an zwei Stellen und lief auseinander." },
],
},
{
version: "0.2.0",
date: "2026-07-25",
headline: "Konten und Härtung.",
changes: [
{ kind: "neu", text: "Benutzerverwaltung mit Passwort und Passkeys, Einzel- und Mehrbenutzerbetrieb." },
{ kind: "neu", text: "Export, Import und vollständige Sicherung." },
{ kind: "neu", text: "Automatischer Abgleich nach Zeitplan, einstellbar im Adminbereich." },
{
kind: "besser",
text:
"Härtung: CSRF-Prüfung, Content-Security-Policy mit Nonce, SSRF-Schutz mit " +
"Namensauflösung, Ratenbegrenzung und harte Datentrennung je Konto.",
},
{
kind: "behoben",
text:
"Menüs verschwanden hinter den Karten. Ursache war der Weichzeichner der " +
"Glasoptik, der einen eigenen Bezugsrahmen aufspannt; Menüs hängen seitdem am " +
"Seitenende statt in der Karte.",
},
{
kind: "behoben",
text:
"Eingabefelder überlappten ihre Symbole, weil eigene CSS-Klassen außerhalb der " +
"Kaskadenschichten gegen die Hilfsklassen gewannen.",
},
],
},
{
version: "0.1.0",
date: "2026-07-25",
headline: "Der Anfang.",
changes: [
{
kind: "neu",
text:
"Projekte mit Status, Priorität, Fortschritt, Tags und Notizen — von der reinen " +
"Idee bis zum fertigen Repo.",
},
{ kind: "neu", text: "Git-Anbindung an GitHub, GitLab und Gitea über Access Token." },
{ kind: "neu", text: "Betrieb per Docker oder eigenständig, Daten in einer lokalen PostgreSQL." },
],
},
];
+19
View File
@@ -12,6 +12,17 @@ function trimmed(value: string | undefined): string | null {
return result ? result : null; return result ? result : null;
} }
import packageJson from "../../package.json";
/**
* Version aus der package.json – eine einzige Quelle. Vorher stand sie
* zusaetzlich in der Fusszeile und lief prompt auseinander.
*
* Bewusst als Standardimport: ein benannter Import aus einer JSON-Datei
* loest in Webpack eine Warnung aus.
*/
export const APP_VERSION: string = packageJson.version;
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */ /** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula"; export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
@@ -68,6 +79,14 @@ export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session"; export const SESSION_COOKIE = "nebula_session";
/**
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
* vorne an.
*/
export const MFA_COOKIE = "nebula_mfa";
export const MFA_TTL_MS = 5 * 60 * 1000;
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */ /** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
export function describeConfig(): string { export function describeConfig(): string {
return [ return [
+57 -2
View File
@@ -12,10 +12,17 @@ import { z } from "zod";
* ein Abgleich nach dem Import holt sie zurueck. * ein Abgleich nach dem Import holt sie zurueck.
* - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert * - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert
* wurde. * wurde.
* - kein Verlauf. Er beschreibt, was in *dieser* Instanz passiert ist, und
* verwiese nach dem Import auf Konten, die es dort nicht gibt. Der Import
* setzt stattdessen einen eigenen ersten Eintrag.
*/ */
export const EXPORT_KIND = "nebula.projects"; export const EXPORT_KIND = "nebula.projects";
export const EXPORT_VERSION = 1; /**
* 2 seit den mehreren Repositories je Projekt. Dateien der Version 1 werden
* weiter gelesen: ihre einzelnen Repo-Felder werden zum ersten Repository.
*/
export const EXPORT_VERSION = 2;
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
@@ -27,12 +34,32 @@ export const exportedNoteSchema = z.object({
createdAt: z.string().optional(), createdAt: z.string().optional(),
}); });
export const exportedTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(5000).nullable().optional(),
status: z.enum(["TODO", "DOING", "BLOCKED", "DONE"]).optional().default("TODO"),
position: z.number().int().min(0).optional().default(0),
dueDate: z.string().nullable().optional(),
labels: z.array(z.string().max(30)).max(8).optional().default([]),
createdAt: z.string().optional(),
});
export const exportedConnectionSchema = z.object({ export const exportedConnectionSchema = z.object({
name: z.string().max(80), name: z.string().max(80),
provider: z.enum(PROVIDERS), provider: z.enum(PROVIDERS),
baseUrl: z.string().max(300), baseUrl: z.string().max(300),
}); });
export const exportedRepositorySchema = z.object({
label: z.string().max(80).nullable().optional(),
repoUrl: z.string().max(500).nullable().optional(),
repoOwner: z.string().max(200).nullable().optional(),
repoName: z.string().max(200).nullable().optional(),
autoSync: z.boolean().optional().default(true),
/** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(),
});
export const exportedProjectSchema = z.object({ export const exportedProjectSchema = z.object({
name: z.string().min(1).max(120), name: z.string().min(1).max(120),
slug: z.string().max(60).optional(), slug: z.string().max(60).optional(),
@@ -44,13 +71,21 @@ export const exportedProjectSchema = z.object({
accent: z.string().max(20).optional().default("violet"), accent: z.string().max(20).optional().default("violet"),
tags: z.array(z.string().max(30)).max(12).optional().default([]), tags: z.array(z.string().max(30)).max(12).optional().default([]),
favorite: z.boolean().optional().default(false), favorite: z.boolean().optional().default(false),
/** Seit Version 2. */
repositories: z.array(exportedRepositorySchema).max(20).optional(),
/**
* Version 1 kannte genau ein Repository und schrieb es flach ans Projekt.
* Die Felder bleiben lesbar, damit aeltere Dateien weiter importierbar
* sind; geschrieben werden sie nicht mehr.
*/
autoSync: z.boolean().optional().default(true), autoSync: z.boolean().optional().default(true),
repoUrl: z.string().max(500).nullable().optional(), repoUrl: z.string().max(500).nullable().optional(),
repoOwner: z.string().max(200).nullable().optional(), repoOwner: z.string().max(200).nullable().optional(),
repoName: z.string().max(200).nullable().optional(), repoName: z.string().max(200).nullable().optional(),
/** Nur zur Wiedererkennung – ohne Token. */
connection: exportedConnectionSchema.nullable().optional(), connection: exportedConnectionSchema.nullable().optional(),
notes: z.array(exportedNoteSchema).max(500).optional().default([]), notes: z.array(exportedNoteSchema).max(500).optional().default([]),
tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
progressFromTasks: z.boolean().optional().default(false),
createdAt: z.string().optional(), createdAt: z.string().optional(),
}); });
@@ -63,6 +98,26 @@ export const exportFileSchema = z.object({
}); });
export type ExportedProject = z.infer<typeof exportedProjectSchema>; export type ExportedProject = z.infer<typeof exportedProjectSchema>;
export type ExportedRepository = z.infer<typeof exportedRepositorySchema>;
/**
* Die Repositories einer Exportdatei – gleich, ob sie aus Version 2 stammt
* oder aus der Zeit mit genau einem Repo je Projekt.
*/
export function repositoriesOf(project: ExportedProject): ExportedRepository[] {
if (project.repositories?.length) return project.repositories;
if (!project.repoUrl && !(project.repoOwner && project.repoName)) return [];
return [
{
label: null,
repoUrl: project.repoUrl ?? null,
repoOwner: project.repoOwner ?? null,
repoName: project.repoName ?? null,
autoSync: project.autoSync,
connection: project.connection ?? null,
},
];
}
export type ExportFile = z.infer<typeof exportFileSchema>; export type ExportFile = z.infer<typeof exportFileSchema>;
/** Dateiname ohne Sonderzeichen, mit Datumsstempel. */ /** Dateiname ohne Sonderzeichen, mit Datumsstempel. */

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