8 Commits
Author SHA1 Message Date
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
73 changed files with 5059 additions and 345 deletions
+31 -2
View File
@@ -69,15 +69,31 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
`prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen: `prisma migrate dev` ist hier nicht benutzbar (interaktiv). Stattdessen:
```bash ```bash
npx prisma migrate diff --from-schema-datasource prisma/schema.prisma \ docker exec nebula-db psql -U nebula -d postgres -c "CREATE DATABASE nebula_shadow"
--to-schema-datamodel prisma/schema.prisma --script --output prisma/migrations/<stempel>_<name>/migration.sql 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 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 **Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird `Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird
bislang in Ruhe gelassen, aber das ist nicht garantiert). 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 ## Prüfen, nicht behaupten
Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`. Vor „fertig": `npx tsc --noEmit`, `npm test`, `npm run build`.
@@ -86,6 +102,19 @@ Bei Änderungen an der Oberfläche **hinsehen**. Chrome ist installiert, ein
Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots. Skript mit `playwright-core` und `channel: "chrome"` genügt für Screenshots.
Mehrere Anzeigefehler dieses Projekts sind ausschließlich so aufgefallen. 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 **Niemals gegen die echte Datenbank testen.** Eine Wegwerf-Datenbank anlegen
und danach löschen: und danach löschen:
+69 -6
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)
@@ -117,6 +125,19 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
- **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.
### Kanban ### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -152,6 +173,19 @@ Blockiert · Erledigt**.
- Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen - Erledigtes wird durchgestrichen; der Zeitpunkt wird festgehalten und beim Zurückziehen
wieder gelöscht. 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 ### Fortschritt aus Aufgaben
Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter Pro Projekt umschaltbar. Ist es an, ergibt sich der Fortschritt aus dem Anteil erledigter
@@ -189,6 +223,31 @@ statt verschoben.
## 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 |
@@ -415,13 +474,17 @@ letzten Zustellung stehen an der Karte.
| `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 |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit und Labels | | `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 | | `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
+9 -1
View File
@@ -324,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) |
@@ -344,9 +347,14 @@ ausschließlich Daten des angemeldeten Kontos.
| `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen | | `PATCH` `DELETE` | `/api/notifications/:id` | Ziel ändern / entfernen |
| `POST` | `/api/notifications/:id/test` | Testnachricht senden | | `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 | | `GET` `POST` | `/api/projects/:id/tasks` | Aufgaben des Projekts |
| `PATCH` | `/api/projects/:id/tasks/reorder` | Neue Reihenfolge nach dem Ziehen | | `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 | | `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 |
+3 -14
View File
@@ -37,17 +37,7 @@ Frei sortieren hieße, eine Position je Projekt zu speichern.
## 3 · Größere Brocken ## 3 · Größere Brocken
### 3.1 Mehrere Repositories je Projekt `M` ### 3.1 Passwort-Zurücksetzen per E-Mail `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
und Anpassungen in der Anzeige.
### 3.2 Zwei-Faktor per TOTP `M`
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
erreichbar ist und nicht jedes Gerät Passkeys kann.
### 3.3 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP 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.
@@ -63,12 +53,9 @@ 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 | | 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 | | Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" |
| Aufgaben aus Git-Issues übernehmen | `M` | Offene Issues als Aufgaben einhängen |
| 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` |
@@ -112,6 +99,8 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.6.0** Ende-zu-Ende-Tests mit Playwright, CI-Pipeline für Gitea Actions | ✓ | | **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.7.0** Benachrichtigungen über ntfy, Webhook und E-Mail | ✓ |
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ | | **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 | ✓ |
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
+134
View File
@@ -184,6 +184,101 @@ test("Aufgaben: Reihenfolge, abgeleiteter Fortschritt, fremde IDs", async ({ req
expect(unveraendert.status).toBe("TODO"); 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("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN); await login(request, ADMIN);
@@ -204,6 +299,12 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async (
const datei = JSON.parse(roh); const datei = JSON.parse(roh);
const vorher = datei.projects.length; 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. // Ueberspringen aendert nichts.
const uebersprungen = await request.post("/api/projects/import", { const uebersprungen = await request.post("/api/projects/import", {
data: { data: datei, strategy: "skip", dryRun: false }, data: { data: datei, strategy: "skip", dryRun: false },
@@ -225,3 +326,36 @@ test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async (
}); });
expect(muell.status()).toBe(422); 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);
});
+21
View File
@@ -98,6 +98,27 @@ test("Volltextsuche findet Notizen", async ({ page }) => {
await expect(page.getByText("Treffer in Notizen")).toBeVisible(); 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("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => { test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click(); await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click(); await page.getByRole("menuitem", { name: "Abmelden" }).click();
+319 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.6.0", "version": "0.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "0.6.0", "version": "0.9.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -18,6 +18,7 @@
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.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",
@@ -30,6 +31,7 @@
"@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/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",
"prisma": "^6.8.2", "prisma": "^6.8.2",
@@ -2331,6 +2333,16 @@
"@types/node": "*" "@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",
@@ -2475,6 +2487,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",
@@ -2538,6 +2574,15 @@
} }
} }
}, },
"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",
@@ -2650,6 +2695,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",
@@ -2707,6 +2781,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",
@@ -2776,6 +2859,12 @@
"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": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -2800,6 +2889,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",
@@ -2969,6 +3064,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",
@@ -2984,6 +3092,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",
@@ -3114,6 +3231,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",
@@ -3419,6 +3545,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",
@@ -4457,6 +4595,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",
@@ -4482,6 +4656,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",
@@ -4573,6 +4756,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "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",
@@ -4673,6 +4865,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",
@@ -4832,6 +5041,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",
@@ -4885,6 +5109,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",
@@ -4970,6 +5200,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",
@@ -4984,6 +5228,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",
@@ -5457,6 +5713,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",
@@ -5474,6 +5736,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",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.8.0", "version": "1.0.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": {
@@ -43,6 +43,7 @@
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "^15.3.3", "next": "^15.3.3",
"nodemailer": "^9.0.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",
@@ -55,6 +56,7 @@
"@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/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",
"prisma": "^6.8.2", "prisma": "^6.8.2",
@@ -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";
+160 -25
View File
@@ -52,6 +52,25 @@ enum NotificationEvent {
PROJECT_STALE 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. /// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus { enum TaskStatus {
TODO TODO
@@ -93,11 +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[] notificationTargets NotificationTarget[]
activities Activity[]
recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -142,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 {
@@ -218,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
@@ -248,15 +316,61 @@ model Project {
tags String[] @default([]) tags String[] @default([])
favorite Boolean @default(false) favorite Boolean @default(false)
// ── Git-Anbindung ────────────────────────────────────────── /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// 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())
updatedAt DateTime @updatedAt
@@unique([ownerId, slug])
@@index([status])
@@index([updatedAt])
@@index([ownerId])
}
/// 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? repoUrl String?
/// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe") /// Owner bzw. Namespace/Gruppe (bei GitLab auch verschachtelt: "gruppe/subgruppe")
repoOwner String? repoOwner String?
repoName String? repoName String?
defaultBranch String? defaultBranch String?
connectionId String? connectionId String?
connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull) connection GitConnection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)
/// Reihenfolge in der Anzeige.
position Int @default(0)
lastSyncedAt DateTime? lastSyncedAt DateTime?
syncError String? syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei /// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
@@ -266,37 +380,21 @@ model Project {
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert. /// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0) syncFailureCount Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen.
ownerId String?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
/// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt
/// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt.
progressFromTasks Boolean @default(false)
/// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer /// Woruber zuletzt benachrichtigt wurde. Gemeldet wird nur bei einer
/// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem /// Aenderung – sonst kaeme bei einem dauerhaft kaputten Repo mit jedem
/// Abgleich dieselbe Nachricht. /// Abgleich dieselbe Nachricht. Je Repository, nicht je Projekt: sonst
/// verdeckt ein rotes Frontend die rote Pipeline des Backends.
notifiedSyncError String? notifiedSyncError String?
notifiedCiState String? notifiedCiState String?
notifiedRelease String? notifiedRelease String?
/// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird
/// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde.
notifiedStaleAt DateTime?
notes Note[]
tasks Task[]
snapshot RepoSnapshot? snapshot RepoSnapshot?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([ownerId, slug]) @@index([projectId, position])
@@index([status])
@@index([connectionId]) @@index([connectionId])
@@index([updatedAt])
@@index([ownerId])
} }
/// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto. /// Ein Zustellweg fuer Benachrichtigungen, gehoert genau einem Konto.
@@ -347,12 +445,49 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime? 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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]) @@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.
model Note { model Note {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -368,12 +503,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?
+11 -1
View File
@@ -18,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, targets] = 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: {
@@ -58,6 +58,11 @@ export default async function AccountPage() {
}, },
orderBy: { name: "asc" }, 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 = {
@@ -69,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 (
+3 -1
View File
@@ -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: [
@@ -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 });
}
+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 });
} }
@@ -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 });
}
+9 -3
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,11 +24,17 @@ 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,
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, // Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
// die Datei auch ohne Netz vollstaendig ist.
repositories: {
include: {
connection: { select: { name: true, provider: true, baseUrl: true } }, connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true, snapshot: true,
}, },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
}); });
if (!project) return jsonError("Projekt nicht gefunden", 404); if (!project) return jsonError("Projekt nicht gefunden", 404);
+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 });
}
+73 -17
View File
@@ -1,8 +1,10 @@
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 { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; import { 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 { refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, projectUpdateSchema } from "@/lib/validation"; import { formatZodError, projectUpdateSchema } from "@/lib/validation";
@@ -75,29 +77,27 @@ export async function PATCH(request: Request, { params }: Params) {
"accent", "accent",
"tags", "tags",
"favorite", "favorite",
"autoSync",
"progressFromTasks", "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) {
update.repoUrl = data.repoUrl;
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null;
if (parsedUrl) {
update.repoOwner = data.repoOwner ?? parsedUrl.owner;
update.repoName = data.repoName ?? parsedUrl.name;
} else if (!data.repoUrl) {
update.repoOwner = null;
update.repoName = null;
}
}
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName;
await prisma.project.update({ where: { id }, data: 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 // Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel. // erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) { if (data.progressFromTasks === true) {
@@ -112,6 +112,62 @@ export async function PATCH(request: Request, { params }: Params) {
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,
});
}
+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";
@@ -65,5 +66,13 @@ export async function POST(request: Request, { params }: Params) {
// Damit die Sortierung nach Aktivitaet stimmt. // 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: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 }); return NextResponse.json({ task }, { status: 201 });
} }
+50 -16
View File
@@ -1,8 +1,13 @@
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 {
exportFileSchema,
repositoriesOf,
type ExportedRepository,
} from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects"; import { uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { import {
@@ -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") {
@@ -154,11 +173,6 @@ 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,
repoUrl: incoming.repoUrl ?? null,
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null,
progressFromTasks: incoming.progressFromTasks, progressFromTasks: incoming.progressFromTasks,
}; };
@@ -194,23 +208,43 @@ export async function POST(request: Request) {
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 } }), 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 }, tasks: { create: tasks } }, 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 }, 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",
+29 -18
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 { 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,34 @@ 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,
repoUrl: data.repoUrl ?? null,
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 });
}
+25 -2
View File
@@ -1,8 +1,10 @@
import type { TaskStatus } from "@prisma/client";
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";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks"; import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation"; import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,7 +15,7 @@ type Params = { params: Promise<{ id: string }> };
async function ownedTask(taskId: string, ownerId: string) { async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({ return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } }, where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true }, select: { id: true, projectId: true, status: true, title: true },
}); });
} }
@@ -57,6 +59,20 @@ export async function PATCH(request: Request, { params }: Params) {
const task = await prisma.task.update({ where: { id }, data: update }); const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId); 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 }); return NextResponse.json({ task });
} }
@@ -71,5 +87,12 @@ export async function DELETE(_request: Request, { params }: Params) {
await prisma.task.delete({ where: { id } }); await prisma.task.delete({ where: { id } });
await refreshDerivedProgress(existing.projectId); 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 }); 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" },
}); });
+4
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
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, APP_VERSION } from "@/lib/config"; import { APP_NAME, APP_TAGLINE, APP_VERSION } from "@/lib/config";
@@ -45,6 +46,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>
+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
+7 -2
View File
@@ -1,6 +1,7 @@
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";
@@ -85,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>
);
}
+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 ? (
+201 -15
View File
@@ -12,22 +12,29 @@ import {
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 { CiBadge } from "@/components/CiBadge";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { Sparkline } from "@/components/Sparkline"; 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, TicketInfo,
} from "@/types"; } from "@/types";
@@ -44,13 +51,56 @@ 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>("pulls"); 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);
@@ -60,13 +110,15 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
const issues = asArray<TicketInfo>(snapshot?.issues); const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity); 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();
@@ -80,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"> <div className="glass px-5 py-10 text-center">
<GitBranch className="mx-auto h-8 w-8 text-slate-600" /> <GitBranch className="mx-auto h-8 w-8 text-slate-600" />
<h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3> <h3 className="mt-3 text-sm font-semibold text-slate-200">Kein Repository verknuepft</h3>
<p className="mx-auto mt-1 max-w-sm text-sm text-slate-500"> <p className="mx-auto mt-1 max-w-sm text-sm text-slate-500">
Hinterlege im Projekt eine Repository-URL und waehle eine Git-Verbindung, um Verknuepfe ein Repository, um Branches, Tags, Commits und Releases hier zu sehen.
Branches, Tags, Commits und Releases hier zu sehen. Ein Projekt darf auch mehrere haben – etwa Frontend und Backend.
</p> </p>
<button type="button" className="btn btn-primary mt-4" onClick={() => setAddOpen(true)}>
<Plus className="h-4 w-4" />
Repository verknuepfen
</button>
</div> </div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
</>
); );
} }
@@ -100,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">
@@ -131,13 +249,50 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} url={snapshot?.ciUrl} /> <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">
{repositories.length > 1 ? (
<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>
) : (
<button
type="button"
onClick={() => setAddOpen(true)}
className="btn btn-ghost"
title="Weiteres Repository verknuepfen"
>
<Plus className="h-4 w-4" />
Repository
</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 ? ( {syncing ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -146,6 +301,14 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
Jetzt synchronisieren Jetzt synchronisieren
</button> </button>
</div> </div>
</div>
<RepositoryDialog
open={addOpen}
onClose={() => setAddOpen(false)}
projectId={project.id}
connections={connections}
/>
{message ? ( {message ? (
<p <p
@@ -160,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}
@@ -329,7 +492,30 @@ export function GitPanel({ project }: { project: ProjectDetail }) {
/> />
) : null} ) : null}
{tab === "issues" ? ( {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} /> <TicketList tickets={issues} empty="Keine offenen Issues." icon={CircleDot} />
</>
) : null} ) : 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}
+4 -3
View File
@@ -256,11 +256,12 @@ function KanbanCard({
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500"> <div className="mt-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] text-slate-500">
<CiBadge state={project.snapshot?.ciState} compact /> {/* Auf der schmalen Kanban-Karte nur das fuehrende Repository. */}
{project.snapshot?.defaultBranch ? ( <CiBadge state={project.repositories[0]?.snapshot?.ciState} compact />
{project.repositories[0]?.snapshot?.defaultBranch ? (
<span className="inline-flex items-center gap-1 font-mono"> <span className="inline-flex items-center gap-1 font-mono">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{project.snapshot.defaultBranch} {project.repositories[0].snapshot.defaultBranch}
</span> </span>
) : null} ) : null}
{project._count.notes > 0 ? ( {project._count.notes > 0 ? (
+3 -1
View File
@@ -140,7 +140,9 @@ 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. // Treffer in Notizen kommen vom Server dazu.
noteHitProjectIds.has(project.id) noteHitProjectIds.has(project.id)
); );
+23 -8
View File
@@ -14,6 +14,7 @@ import {
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { Sparkline } from "@/components/Sparkline"; 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 { asArray, type ProjectListItem } from "@/types"; import { asArray, type ProjectListItem } from "@/types";
@@ -31,7 +32,14 @@ const PROVIDER_LABEL: Record<string, string> = {
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) {
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 activity = asArray<number>(snapshot?.commitActivity);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
@@ -125,10 +133,20 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<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">
<CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact /> <CiBadge state={snapshot?.ciState} name={snapshot?.ciName} compact />
{project.connection ? ( {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}
@@ -173,11 +191,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>
+8 -5
View File
@@ -87,11 +87,14 @@ 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, progressFromTasks: project.progressFromTasks,
} }
: EMPTY, : EMPTY,
+10 -11
View File
@@ -19,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";
@@ -132,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>
) : null}
{project.repoUrl ? (
<a <a
href={project.repoUrl} key={repository.id}
href={repository.repoUrl!}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200" className="inline-flex items-center gap-1.5 text-violet-300 transition-colors hover:text-violet-200"
> >
Repository {project.repositories.length === 1 ? "Repository" : repoTitle(repository)}
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</a> </a>
) : null} ))}
</div> </div>
{project.tags.length > 0 ? ( {project.tags.length > 0 ? (
+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>
);
}
@@ -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>
+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>
);
}
+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 ? (
+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);
}
+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();
}
+8
View File
@@ -79,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 [
+45 -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;
@@ -43,6 +50,16 @@ export const exportedConnectionSchema = z.object({
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(),
@@ -54,11 +71,17 @@ 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([]), tasks: z.array(exportedTaskSchema).max(1000).optional().default([]),
@@ -75,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. */
+68 -26
View File
@@ -1,14 +1,27 @@
import type { GitConnection, Note, Project, RepoSnapshot, Task } from "@prisma/client"; import type {
GitConnection,
Note,
Prisma,
Project,
RepoSnapshot,
Repository,
Task,
} from "@prisma/client";
import { APP_NAME } from "@/lib/config"; import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { repoTitle } from "@/lib/repositories";
import { PRIORITY_META, statusOf } from "@/lib/status"; import { PRIORITY_META, statusOf } from "@/lib/status";
import { formatDate, formatDateTime } from "@/lib/utils"; import { formatDate, formatDateTime } from "@/lib/utils";
import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format"; import { EXPORT_KIND, EXPORT_VERSION, type ExportFile, type ExportedProject } from "./format";
type ProjectForExport = Project & { export type RepositoryForExport = Repository & {
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null;
};
export type ProjectForExport = Project & {
notes: Note[]; notes: Note[];
tasks: Task[]; tasks: Task[];
connection: Pick<GitConnection, "name" | "provider" | "baseUrl"> | null; repositories: RepositoryForExport[];
}; };
/** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */ /** Formt einen Datensatz in das Austauschformat um – ohne Geheimnisse. */
@@ -24,17 +37,25 @@ export function toExportedProject(project: ProjectForExport): ExportedProject {
accent: project.accent, accent: project.accent,
tags: project.tags, tags: project.tags,
favorite: project.favorite, favorite: project.favorite,
autoSync: project.autoSync, // Version 1 hatte ein autoSync am Projekt. Das Feld ist im Schema noch
repoUrl: project.repoUrl, // vorhanden, damit alte Dateien lesbar bleiben; geschrieben wird der
repoOwner: project.repoOwner, // Wert des fuehrenden Repositories, damit eine Version-2-Datei auch von
repoName: project.repoName, // einer aelteren Instanz noch sinnvoll gelesen werden koennte.
connection: project.connection autoSync: project.repositories[0]?.autoSync ?? true,
repositories: project.repositories.map((repository) => ({
label: repository.label,
repoUrl: repository.repoUrl,
repoOwner: repository.repoOwner,
repoName: repository.repoName,
autoSync: repository.autoSync,
connection: repository.connection
? { ? {
name: project.connection.name, name: repository.connection.name,
provider: project.connection.provider, provider: repository.connection.provider,
baseUrl: project.connection.baseUrl, baseUrl: repository.connection.baseUrl,
} }
: null, : null,
})),
notes: project.notes.map((note) => ({ notes: project.notes.map((note) => ({
title: note.title, title: note.title,
content: note.content, content: note.content,
@@ -72,15 +93,21 @@ export async function loadProjectsForExport(
): Promise<ProjectForExport[]> { ): Promise<ProjectForExport[]> {
return prisma.project.findMany({ return prisma.project.findMany({
where: { ownerId, ...(projectId ? { id: projectId } : {}) }, where: { ownerId, ...(projectId ? { id: projectId } : {}) },
include: { include: exportInclude,
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
},
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
} }
/** Der Umfang, den der Export braucht – an beiden Aufrufstellen derselbe. */
export const exportInclude = {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
repositories: {
include: { connection: { select: { name: true, provider: true, baseUrl: true } } },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
} satisfies Prisma.ProjectInclude;
// ── Markdown ───────────────────────────────────────────────── // ── Markdown ─────────────────────────────────────────────────
const PROVIDER_LABEL: Record<string, string> = { const PROVIDER_LABEL: Record<string, string> = {
@@ -102,7 +129,9 @@ const TASK_LABEL: Record<string, string> = {
* Datei auch ohne Netz vollstaendig ist. * Datei auch ohne Netz vollstaendig ist.
*/ */
export function toMarkdown( export function toMarkdown(
project: ProjectForExport & { snapshot: RepoSnapshot | null }, project: ProjectForExport & {
repositories: Array<RepositoryForExport & { snapshot: RepoSnapshot | null }>;
},
): string { ): string {
const status = statusOf(project.status); const status = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
@@ -119,22 +148,31 @@ export function toMarkdown(
lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`); lines.push(`| Tags | ${project.tags.map((tag) => `\`${tag}\``).join(", ")} |`);
} }
lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`); lines.push(`| Angelegt | ${formatDateTime(project.createdAt)} |`);
if (project.repoUrl) lines.push(`| Repository | ${project.repoUrl} |`);
if (project.connection) {
const label = PROVIDER_LABEL[project.connection.provider] ?? project.connection.provider;
lines.push(`| Git-System | ${label} (${project.connection.baseUrl}) |`);
}
lines.push(""); lines.push("");
if (project.description) { if (project.description) {
lines.push("## Beschreibung", "", project.description, ""); lines.push("## Beschreibung", "", project.description, "");
} }
const snapshot = project.snapshot; for (const repository of project.repositories) {
if (snapshot) { const titel = repoTitle(repository);
lines.push("## Stand des Repositories", ""); lines.push(
lines.push(`Abgerufen am ${formatDateTime(snapshot.fetchedAt)}.`, ""); project.repositories.length === 1
? "## Repository"
: `## Repository: ${titel}`,
"",
);
lines.push("| | |", "| --- | --- |"); lines.push("| | |", "| --- | --- |");
if (repository.repoUrl) lines.push(`| Adresse | ${repository.repoUrl} |`);
if (repository.connection) {
const label =
PROVIDER_LABEL[repository.connection.provider] ?? repository.connection.provider;
lines.push(`| Git-System | ${label} (${repository.connection.baseUrl}) |`);
}
const snapshot = repository.snapshot;
if (snapshot) {
if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`); if (snapshot.defaultBranch) lines.push(`| Default-Branch | \`${snapshot.defaultBranch}\` |`);
if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`); if (snapshot.latestTag) lines.push(`| Neuester Tag | \`${snapshot.latestTag}\` |`);
lines.push(`| Branches | ${snapshot.branchCount} |`); lines.push(`| Branches | ${snapshot.branchCount} |`);
@@ -147,6 +185,10 @@ export function toMarkdown(
`${snapshot.lastCommitMessage ?? ""} |`, `${snapshot.lastCommitMessage ?? ""} |`,
); );
} }
lines.push(`| Abgerufen | ${formatDateTime(snapshot.fetchedAt)} |`);
} else {
lines.push("| Stand | noch nie abgeglichen |");
}
lines.push(""); lines.push("");
} }
+19
View File
@@ -29,6 +29,25 @@ export const PROVIDER_META: Record<
}, },
}; };
/**
* Wie ein Repository in Ueberschriften und Meldungen heisst.
*
* Steht hier und nicht bei den uebrigen Repository-Helfern, weil die
* Oberflaeche sie braucht: lib/repositories zieht Prisma und damit
* node:-Module nach, die in einer Client-Komponente nicht landen duerfen.
*/
export function repoTitle(repo: {
label?: string | null;
repoOwner?: string | null;
repoName?: string | null;
repoUrl?: string | null;
}): string {
if (repo.label) return repo.label;
if (repo.repoOwner && repo.repoName) return `${repo.repoOwner}/${repo.repoName}`;
if (repo.repoName) return repo.repoName;
return repo.repoUrl ?? "Repository";
}
export function guessProvider(host: string): ProviderKind { export function guessProvider(host: string): ProviderKind {
const lower = host.toLowerCase(); const lower = host.toLowerCase();
if (lower.includes("github")) return "GITHUB"; if (lower.includes("github")) return "GITHUB";
+36 -22
View File
@@ -1,6 +1,7 @@
import type { NotificationEvent, Project, RepoSnapshot } from "@prisma/client"; import type { NotificationEvent, Project, RepoSnapshot, Repository } from "@prisma/client";
import { APP_NAME, APP_URL } from "@/lib/config"; import { APP_NAME, APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { repoTitle } from "@/lib/repositories";
import { deliver } from "./channels"; import { deliver } from "./channels";
import { EVENT_META, type NotificationMessage } from "./types"; import { EVENT_META, type NotificationMessage } from "./types";
@@ -14,25 +15,26 @@ export { emailAvailable, smtpConfig } from "./channels";
* Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem, * Gemeldet wird ausschliesslich bei einer **Aenderung** gegenueber dem,
* worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit * worueber zuletzt benachrichtigt wurde. Sonst kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht. * abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
* Der zuletzt gemeldete Zustand steht am Projekt. * Der zuletzt gemeldete Zustand steht am Repository – nicht am Projekt, sonst
* verdeckte ein rotes Frontend die rote Pipeline des Backends.
* *
* Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung * Fehler beim Zustellen werden geschluckt: eine kaputte Benachrichtigung
* darf den Abgleich nicht scheitern lassen. * darf den Abgleich nicht scheitern lassen.
*/ */
export async function notifyAfterSync(projectId: string): Promise<void> { export async function notifyAfterSync(repositoryId: string): Promise<void> {
try { try {
const project = await prisma.project.findUnique({ const repository = await prisma.repository.findUnique({
where: { id: projectId }, where: { id: repositoryId },
include: { snapshot: true }, include: { snapshot: true, project: true },
}); });
if (!project?.ownerId) return; if (!repository?.project.ownerId) return;
const faellig = pendingEvents(project, project.snapshot); const faellig = pendingEvents(repository.project, repository, repository.snapshot);
if (faellig.length === 0) return; if (faellig.length === 0) return;
const ziele = await prisma.notificationTarget.findMany({ const ziele = await prisma.notificationTarget.findMany({
where: { where: {
userId: project.ownerId, userId: repository.project.ownerId,
isActive: true, isActive: true,
events: { hasSome: faellig.map((entry) => entry.event) }, events: { hasSome: faellig.map((entry) => entry.event) },
}, },
@@ -40,12 +42,12 @@ export async function notifyAfterSync(projectId: string): Promise<void> {
// Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten // Auch ohne Ziel den Merkstand fortschreiben: sonst kaeme beim ersten
// eingerichteten Ziel gleich die gesamte Vergangenheit hinterher. // eingerichteten Ziel gleich die gesamte Vergangenheit hinterher.
await prisma.project.update({ await prisma.repository.update({
where: { id: project.id }, where: { id: repository.id },
data: { data: {
notifiedSyncError: project.syncError, notifiedSyncError: repository.syncError,
notifiedCiState: project.snapshot?.ciState ?? null, notifiedCiState: repository.snapshot?.ciState ?? null,
notifiedRelease: currentRelease(project.snapshot), notifiedRelease: currentRelease(repository.snapshot),
}, },
}); });
@@ -81,6 +83,7 @@ function currentRelease(snapshot: RepoSnapshot | null): string | null {
*/ */
export function pendingEvents( export function pendingEvents(
project: Project, project: Project,
repository: Repository,
snapshot: RepoSnapshot | null, snapshot: RepoSnapshot | null,
): Array<{ event: NotificationEvent; message: NotificationMessage }> { ): Array<{ event: NotificationEvent; message: NotificationMessage }> {
const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = []; const treffer: Array<{ event: NotificationEvent; message: NotificationMessage }> = [];
@@ -88,13 +91,24 @@ export function pendingEvents(
const bezug = { id: project.id, name: project.name, slug: project.slug }; const bezug = { id: project.id, name: project.name, slug: project.slug };
const jetzt = new Date().toISOString(); const jetzt = new Date().toISOString();
if (project.syncError && project.syncError !== project.notifiedSyncError) { /**
* Der Betreff nennt das Repository nur, wenn es nicht ohnehin so heisst
* wie das Projekt. "nebula – nebula: CI ist rot" liest niemand gern.
*/
const titel = repoTitle(repository);
const woher =
titel.toLowerCase() === project.name.toLowerCase() ||
titel.toLowerCase().endsWith(`/${project.name.toLowerCase()}`)
? project.name
: `${project.name} · ${titel}`;
if (repository.syncError && repository.syncError !== repository.notifiedSyncError) {
treffer.push({ treffer.push({
event: "SYNC_FAILED", event: "SYNC_FAILED",
message: { message: {
event: "SYNC_FAILED", event: "SYNC_FAILED",
title: `${EVENT_META.SYNC_FAILED.emoji} ${project.name}: Abgleich fehlgeschlagen`, title: `${EVENT_META.SYNC_FAILED.emoji} ${woher}: Abgleich fehlgeschlagen`,
body: project.syncError, body: repository.syncError,
priority: "high", priority: "high",
url: projektUrl, url: projektUrl,
project: bezug, project: bezug,
@@ -104,12 +118,12 @@ export function pendingEvents(
} }
const ci = snapshot?.ciState ?? null; const ci = snapshot?.ciState ?? null;
if (ci === "failure" && ci !== project.notifiedCiState) { if (ci === "failure" && ci !== repository.notifiedCiState) {
treffer.push({ treffer.push({
event: "CI_FAILED", event: "CI_FAILED",
message: { message: {
event: "CI_FAILED", event: "CI_FAILED",
title: `${EVENT_META.CI_FAILED.emoji} ${project.name}: CI ist rot`, title: `${EVENT_META.CI_FAILED.emoji} ${woher}: CI ist rot`,
body: body:
`Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` + `Der letzte Lauf auf ${snapshot?.ciBranch ?? "dem Default-Branch"} ist ` +
`fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`, `fehlgeschlagen${snapshot?.ciName ? ` (${snapshot.ciName})` : ""}.`,
@@ -125,13 +139,13 @@ export function pendingEvents(
// Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere // Beim allerersten Abgleich ist notifiedRelease noch leer – dann waere
// jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn // jede vorhandene Version eine "Neuigkeit". Deshalb nur melden, wenn
// vorher schon einmal etwas festgehalten wurde. // vorher schon einmal etwas festgehalten wurde.
if (release && project.notifiedRelease && release !== project.notifiedRelease) { if (release && repository.notifiedRelease && release !== repository.notifiedRelease) {
treffer.push({ treffer.push({
event: "NEW_RELEASE", event: "NEW_RELEASE",
message: { message: {
event: "NEW_RELEASE", event: "NEW_RELEASE",
title: `${EVENT_META.NEW_RELEASE.emoji} ${project.name}: ${release}`, title: `${EVENT_META.NEW_RELEASE.emoji} ${woher}: ${release}`,
body: `Neue Version im Repository: ${release} (vorher ${project.notifiedRelease}).`, body: `Neue Version im Repository: ${release} (vorher ${repository.notifiedRelease}).`,
priority: "normal", priority: "normal",
url: snapshot?.htmlUrl ?? projektUrl, url: snapshot?.htmlUrl ?? projektUrl,
project: bezug, project: bezug,
+55 -20
View File
@@ -1,24 +1,36 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Project, RepoSnapshot } from "@prisma/client"; import type { Project, RepoSnapshot, Repository } from "@prisma/client";
import { pendingEvents } from "./index"; import { pendingEvents } from "./index";
/** /**
* Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung * Die Entprellung ist der Kern: gemeldet wird nur bei einer Aenderung
* gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit * gegenueber dem zuletzt gemeldeten Stand. Ohne sie kaeme bei einem Repo mit
* abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht. * abgelaufenem Token mit jedem stuendlichen Durchlauf dieselbe Nachricht.
*
* Der Merkstand liegt am Repository, nicht am Projekt – sonst verdeckte ein
* rotes Frontend die rote Pipeline des Backends.
*/ */
function projekt(overrides: Partial<Project> = {}): Project { const PROJEKT: Project = {
return {
id: "p1", id: "p1",
name: "Testprojekt", name: "Testprojekt",
slug: "testprojekt", slug: "testprojekt",
} as Project;
function repo(overrides: Partial<Repository> = {}): Repository {
return {
id: "r1",
projectId: "p1",
label: null,
repoOwner: "wer",
repoName: "testprojekt",
repoUrl: null,
syncError: null, syncError: null,
notifiedSyncError: null, notifiedSyncError: null,
notifiedCiState: null, notifiedCiState: null,
notifiedRelease: null, notifiedRelease: null,
...overrides, ...overrides,
} as Project; } as Repository;
} }
function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot { function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
@@ -34,49 +46,49 @@ function snapshot(overrides: Partial<RepoSnapshot> = {}): RepoSnapshot {
} as RepoSnapshot; } as RepoSnapshot;
} }
const events = (p: Project, s: RepoSnapshot | null) => const events = (r: Repository, s: RepoSnapshot | null) =>
pendingEvents(p, s).map((entry) => entry.event); pendingEvents(PROJEKT, r, s).map((entry) => entry.event);
describe("Abgleichsfehler", () => { describe("Abgleichsfehler", () => {
it("meldet einen neuen Fehler", () => { it("meldet einen neuen Fehler", () => {
expect(events(projekt({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]); expect(events(repo({ syncError: "404" }), null)).toEqual(["SYNC_FAILED"]);
}); });
it("meldet denselben Fehler kein zweites Mal", () => { it("meldet denselben Fehler kein zweites Mal", () => {
expect(events(projekt({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]); expect(events(repo({ syncError: "404", notifiedSyncError: "404" }), null)).toEqual([]);
}); });
it("meldet, wenn sich der Fehler aendert", () => { it("meldet, wenn sich der Fehler aendert", () => {
expect( expect(
events(projekt({ syncError: "401 Token", notifiedSyncError: "404" }), null), events(repo({ syncError: "401 Token", notifiedSyncError: "404" }), null),
).toEqual(["SYNC_FAILED"]); ).toEqual(["SYNC_FAILED"]);
}); });
it("meldet nichts, wenn kein Fehler vorliegt", () => { it("meldet nichts, wenn kein Fehler vorliegt", () => {
expect(events(projekt({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]); expect(events(repo({ syncError: null, notifiedSyncError: "404" }), null)).toEqual([]);
}); });
}); });
describe("CI", () => { describe("CI", () => {
it("meldet den Wechsel auf rot", () => { it("meldet den Wechsel auf rot", () => {
expect(events(projekt(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]); expect(events(repo(), snapshot({ ciState: "failure" }))).toEqual(["CI_FAILED"]);
}); });
it("meldet dauerhaft rote CI nur einmal", () => { it("meldet dauerhaft rote CI nur einmal", () => {
expect( expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })), events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "failure" })),
).toEqual([]); ).toEqual([]);
}); });
it("meldet nicht, wenn die CI gruen ist", () => { it("meldet nicht, wenn die CI gruen ist", () => {
expect( expect(
events(projekt({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })), events(repo({ notifiedCiState: "failure" }), snapshot({ ciState: "success" })),
).toEqual([]); ).toEqual([]);
}); });
it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => { it("meldet erneut, wenn die CI nach gruen wieder rot wird", () => {
expect( expect(
events(projekt({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })), events(repo({ notifiedCiState: "success" }), snapshot({ ciState: "failure" })),
).toEqual(["CI_FAILED"]); ).toEqual(["CI_FAILED"]);
}); });
}); });
@@ -85,40 +97,63 @@ describe("Release", () => {
it("schweigt beim allerersten Abgleich", () => { it("schweigt beim allerersten Abgleich", () => {
// Sonst waere jede bereits vorhandene Version eine "Neuigkeit". // Sonst waere jede bereits vorhandene Version eine "Neuigkeit".
expect( expect(
events(projekt({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })), events(repo({ notifiedRelease: null }), snapshot({ latestTag: "v1.0.0" })),
).toEqual([]); ).toEqual([]);
}); });
it("meldet eine neue Version", () => { it("meldet eine neue Version", () => {
expect( expect(
events(projekt({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })), events(repo({ notifiedRelease: "v1.0.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual(["NEW_RELEASE"]); ).toEqual(["NEW_RELEASE"]);
}); });
it("meldet dieselbe Version nicht erneut", () => { it("meldet dieselbe Version nicht erneut", () => {
expect( expect(
events(projekt({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })), events(repo({ notifiedRelease: "v1.1.0" }), snapshot({ latestTag: "v1.1.0" })),
).toEqual([]); ).toEqual([]);
}); });
it("zieht den Releasenamen dem Tag vor", () => { it("zieht den Releasenamen dem Tag vor", () => {
const treffer = pendingEvents( const treffer = pendingEvents(
projekt({ notifiedRelease: "alt" }), PROJEKT,
repo({ notifiedRelease: "alt" }),
snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }), snapshot({ latestTag: "v2.0.0", latestRelease: "Zweite Ausgabe" }),
); );
expect(treffer[0].message.title).toContain("Zweite Ausgabe"); expect(treffer[0].message.title).toContain("Zweite Ausgabe");
}); });
}); });
describe("Betreff bei mehreren Repositories", () => {
it("nennt das Repository nicht, wenn es ohnehin so heisst wie das Projekt", () => {
// "Testprojekt · wer/testprojekt: CI ist rot" liest niemand gern.
const treffer = pendingEvents(
PROJEKT,
repo({ repoOwner: "wer", repoName: "testprojekt" }),
snapshot({ ciState: "failure" }),
);
expect(treffer[0].message.title).toContain("Testprojekt: CI ist rot");
expect(treffer[0].message.title).not.toContain("wer/");
});
it("nennt es, sobald es sich unterscheidet", () => {
const treffer = pendingEvents(
PROJEKT,
repo({ label: "Backend" }),
snapshot({ ciState: "failure" }),
);
expect(treffer[0].message.title).toContain("Testprojekt · Backend");
});
});
describe("mehrere Anlaesse gleichzeitig", () => { describe("mehrere Anlaesse gleichzeitig", () => {
it("meldet Fehler und rote CI zusammen", () => { it("meldet Fehler und rote CI zusammen", () => {
expect( expect(
events(projekt({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })), events(repo({ syncError: "Zeitueberschreitung" }), snapshot({ ciState: "failure" })),
).toEqual(["SYNC_FAILED", "CI_FAILED"]); ).toEqual(["SYNC_FAILED", "CI_FAILED"]);
}); });
it("haengt Projektbezug und Tieflink an jede Nachricht", () => { it("haengt Projektbezug und Tieflink an jede Nachricht", () => {
const treffer = pendingEvents(projekt({ syncError: "404" }), null); const treffer = pendingEvents(PROJEKT, repo({ syncError: "404" }), null);
expect(treffer[0].message.project?.slug).toBe("testprojekt"); expect(treffer[0].message.project?.slug).toBe("testprojekt");
expect(treffer[0].message.url).toContain("/projects/p1"); expect(treffer[0].message.url).toContain("/projects/p1");
expect(treffer[0].message.priority).toBe("high"); expect(treffer[0].message.priority).toBe("high");
+24
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { newestCommit } from "./stale";
import { daysSince, isStaleDue } from "./stale"; import { daysSince, isStaleDue } from "./stale";
/** /**
@@ -59,3 +60,26 @@ describe("daysSince", () => {
expect(daysSince(jetzt, jetzt)).toBe(0); expect(daysSince(jetzt, jetzt)).toBe(0);
}); });
}); });
describe("newestCommit", () => {
it("nimmt den juengsten Commit ueber alle Repositories", () => {
// An einem Vorhaben wird gearbeitet, sobald an einem seiner Teile
// gearbeitet wird – ein ruhendes Backend macht es nicht stillstehend.
const juengster = newestCommit([
{ lastCommitAt: vorTagen(90) },
{ lastCommitAt: vorTagen(3) },
{ lastCommitAt: vorTagen(40) },
]);
expect(daysSince(juengster!, jetzt)).toBe(3);
});
it("uebergeht Repositories ohne Commit-Datum", () => {
const juengster = newestCommit([{ lastCommitAt: null }, { lastCommitAt: vorTagen(5) }]);
expect(daysSince(juengster!, jetzt)).toBe(5);
});
it("liefert null, wenn nirgends ein Commit bekannt ist", () => {
expect(newestCommit([])).toBeNull();
expect(newestCommit([{ lastCommitAt: null }])).toBeNull();
});
});
+39 -6
View File
@@ -12,10 +12,13 @@ import { EVENT_META, type NotificationMessage } from "./types";
* passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens * passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens
* einmal am Tag. * einmal am Tag.
* *
* Massstab ist ausschliesslich der letzte Commit im verknuepften * Massstab ist ausschliesslich der letzte Commit in den verknuepften
* Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich * Repositories. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich
* fasst es an und wuerde den Stillstand immer wieder zuruecksetzen. * fasst es an und wuerde den Stillstand immer wieder zuruecksetzen.
* Projekte ohne Repository bleiben folglich aussen vor. * Projekte ohne Repository bleiben folglich aussen vor.
*
* Bei mehreren Repositories zaehlt der juengste Commit. Ein Vorhaben steht
* nicht still, solange an einem seiner Teile gearbeitet wird.
*/ */
export interface StaleReport { export interface StaleReport {
@@ -48,6 +51,22 @@ export function daysSince(date: Date, now: Date = new Date()): number {
return Math.floor((now.getTime() - date.getTime()) / 86_400_000); return Math.floor((now.getTime() - date.getTime()) / 86_400_000);
} }
/**
* Der juengste Commit ueber alle Repositories hinweg. Massgeblich ist das
* regste Repo: an einem Vorhaben wird gearbeitet, sobald an irgendeinem
* seiner Teile gearbeitet wird.
*/
export function newestCommit(
snapshots: Array<{ lastCommitAt: Date | null }>,
): Date | null {
let juengster: Date | null = null;
for (const snapshot of snapshots) {
if (!snapshot.lastCommitAt) continue;
if (!juengster || snapshot.lastCommitAt > juengster) juengster = snapshot.lastCommitAt;
}
return juengster;
}
function message( function message(
project: Project, project: Project,
snapshot: RepoSnapshot | null, snapshot: RepoSnapshot | null,
@@ -79,20 +98,34 @@ export async function runStaleCheck(thresholdDays: number): Promise<StaleReport>
// Fertige und archivierte Projekte duerfen ruhen. // Fertige und archivierte Projekte duerfen ruhen.
status: { notIn: ["DONE", "ARCHIVED"] }, status: { notIn: ["DONE", "ARCHIVED"] },
ownerId: { not: null }, ownerId: { not: null },
snapshot: { isNot: null }, repositories: { some: { snapshot: { isNot: null } } },
}, },
include: {
repositories: {
include: { snapshot: true }, include: { snapshot: true },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
}); });
const letzterCommit = new Map(
kandidaten.map((project) => [
project.id,
newestCommit(project.repositories.map((repo) => repo.snapshot).filter((s) => s !== null)),
]),
);
const faellig = kandidaten.filter((project) => const faellig = kandidaten.filter((project) =>
isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff), isStaleDue(project, letzterCommit.get(project.id) ?? null, cutoff),
); );
let zugestellt = 0; let zugestellt = 0;
for (const project of faellig) { for (const project of faellig) {
const lastCommitAt = project.snapshot?.lastCommitAt; const lastCommitAt = letzterCommit.get(project.id);
if (!lastCommitAt || !project.ownerId) continue; if (!lastCommitAt || !project.ownerId) continue;
// Fuer den Link in der Meldung reicht das fuehrende Repository.
const snapshot = project.repositories[0]?.snapshot ?? null;
const ziele = await prisma.notificationTarget.findMany({ const ziele = await prisma.notificationTarget.findMany({
where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } }, where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } },
@@ -101,7 +134,7 @@ export async function runStaleCheck(thresholdDays: number): Promise<StaleReport>
for (const ziel of ziele) { for (const ziel of ziele) {
const ergebnis = await deliver( const ergebnis = await deliver(
ziel, ziel,
message(project, project.snapshot, daysSince(lastCommitAt, now)), message(project, snapshot, daysSince(lastCommitAt, now)),
); );
zugestellt += 1; zugestellt += 1;
await prisma.notificationTarget.update({ await prisma.notificationTarget.update({
+25 -4
View File
@@ -46,9 +46,19 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
updatedAt: true, updatedAt: true,
}); });
/**
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
*/
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
repositories: {
include: { connection: { select: connectionSafeSelect }, snapshot: true },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
});
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect }, ...repositoryInclude,
snapshot: true,
_count: { select: { notes: true, tasks: true } }, _count: { select: { notes: true, tasks: true } },
// Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der // Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
// Karte, ohne die Aufgaben selbst mitzuladen. // Karte, ohne die Aufgaben selbst mitzuladen.
@@ -56,13 +66,24 @@ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
}); });
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
connection: { select: connectionSafeSelect }, ...repositoryInclude,
snapshot: true,
notes: { notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
}, },
tasks: { tasks: {
orderBy: [{ status: "asc" }, { position: "asc" }], orderBy: [{ status: "asc" }, { position: "asc" }],
}, },
// Erste Seite des Verlaufs; weitere holt die Anzeige bei Bedarf nach.
activities: {
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
},
_count: { select: { notes: true, tasks: true } }, _count: { select: { notes: true, tasks: true } },
}); });
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { repoTitle } from "./git/meta";
import { completeRepoInput, namesRepository } from "./repositories";
describe("completeRepoInput", () => {
it("leitet Owner und Name aus der URL ab", () => {
expect(completeRepoInput({ repoUrl: "https://github.com/vercel/swr" })).toMatchObject({
repoOwner: "vercel",
repoName: "swr",
});
});
it("laesst ausdruecklich gesetzte Werte in Ruhe", () => {
// Bei GitLab-Untergruppen weiss der Benutzer es besser als die Ableitung.
expect(
completeRepoInput({
repoUrl: "https://gitlab.com/gruppe/untergruppe/dings",
repoOwner: "gruppe/untergruppe",
repoName: "dings",
}),
).toMatchObject({ repoOwner: "gruppe/untergruppe", repoName: "dings" });
});
it("kommt ohne URL und mit unbrauchbarer URL zurecht", () => {
expect(completeRepoInput({ repoOwner: "a", repoName: "b" }).repoUrl).toBeUndefined();
expect(completeRepoInput({ repoUrl: "kein-link" }).repoOwner).toBeUndefined();
});
});
describe("namesRepository", () => {
it("erkennt, wann ueberhaupt ein Repo benannt wurde", () => {
expect(namesRepository({ repoUrl: "https://github.com/a/b" })).toBe(true);
expect(namesRepository({ repoOwner: "a", repoName: "b" })).toBe(true);
});
it("laesst Halbes nicht durchgehen", () => {
// Ein Owner ohne Namen ergibt kein Repository, sondern eine leere Zeile.
expect(namesRepository({})).toBe(false);
expect(namesRepository({ repoOwner: "a" })).toBe(false);
expect(namesRepository({ label: "Frontend" })).toBe(false);
});
});
describe("repoTitle", () => {
it("zieht die Bezeichnung allem anderen vor", () => {
expect(repoTitle({ label: "Frontend", repoOwner: "a", repoName: "b" })).toBe("Frontend");
});
it("faellt ueber owner/name auf die URL zurueck", () => {
expect(repoTitle({ repoOwner: "vercel", repoName: "swr" })).toBe("vercel/swr");
expect(repoTitle({ repoName: "swr" })).toBe("swr");
expect(repoTitle({ repoUrl: "https://example.com/x" })).toBe("https://example.com/x");
expect(repoTitle({})).toBe("Repository");
});
});
+116
View File
@@ -0,0 +1,116 @@
import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { repoTitle } from "@/lib/git/meta";
// Der Titel steht in lib/git/meta, weil ihn auch Client-Komponenten
// brauchen – dieses Modul zieht Prisma nach. Hier weitergereicht, damit
// serverseitiger Code nur eine Anlaufstelle hat.
export { repoTitle };
/**
* Ein Projekt kann mehrere Repositories haben – Frontend und Backend etwa,
* aber ein Vorhaben.
*
* Das erste (position 0) ist das fuehrende. Wo nur eines dargestellt werden
* kann, ist es dieses: auf der Karte im Dashboard, im Kopf der Projektseite
* und in allem, was die alte Projekt-API weiterhin annimmt.
*/
/** Angaben, aus denen ein Repository entsteht. */
export interface RepoInput {
label?: string | null;
repoUrl?: string | null;
repoOwner?: string | null;
repoName?: string | null;
connectionId?: string | null;
autoSync?: boolean;
}
/**
* Ergaenzt Owner und Name aus der URL, wenn sie nicht angegeben wurden.
* Ohne das muesste man beides von Hand eintragen, obwohl es in der URL steht.
*/
export function completeRepoInput(input: RepoInput): RepoInput {
if (!input.repoUrl || (input.repoOwner && input.repoName)) return input;
const parsed = parseRepoUrl(input.repoUrl);
if (!parsed) return input;
return {
...input,
repoOwner: input.repoOwner ?? parsed.owner,
repoName: input.repoName ?? parsed.name,
};
}
/** Hat die Eingabe ueberhaupt ein Repository benannt? */
export function namesRepository(input: RepoInput): boolean {
return Boolean(input.repoUrl || (input.repoOwner && input.repoName));
}
/** Prueft Existenz und Besitz in einem Zug – ueber das Projekt. */
export async function ownedRepository(repositoryId: string, ownerId: string) {
return prisma.repository.findFirst({
where: { id: repositoryId, project: { ownerId } },
});
}
/** Naechste freie Position am Ende der Liste. */
export async function nextRepoPosition(projectId: string): Promise<number> {
const last = await prisma.repository.findFirst({
where: { projectId },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
/**
* Setzt die Angaben am fuehrenden Repository – und legt es an, falls es noch
* keines gibt.
*
* Damit bleibt die Projekt-API unveraendert bedienbar: wer beim Anlegen oder
* Bearbeiten eine Repo-URL mitgibt, bekommt weiterhin das erwartete Ergebnis,
* ohne von der neuen Tabelle wissen zu muessen.
*
* Eine geleerte URL entfernt das Repository nicht, sondern raeumt nur seine
* Felder – der gesammelte Stand haengt daran, und ein versehentlich
* geleertes Feld soll ihn nicht mitnehmen.
*/
export async function applyLeadingRepo(projectId: string, input: RepoInput): Promise<void> {
const vollstaendig = completeRepoInput(input);
const fuehrend = await prisma.repository.findFirst({
where: { projectId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
select: { id: true },
});
if (!fuehrend) {
if (!namesRepository(vollstaendig)) return;
await prisma.repository.create({
data: {
projectId,
label: vollstaendig.label ?? null,
repoUrl: vollstaendig.repoUrl ?? null,
repoOwner: vollstaendig.repoOwner ?? null,
repoName: vollstaendig.repoName ?? null,
connectionId: vollstaendig.connectionId ?? null,
autoSync: vollstaendig.autoSync ?? true,
position: 0,
},
});
return;
}
const data: Record<string, unknown> = {};
if (vollstaendig.label !== undefined) data.label = vollstaendig.label;
if (vollstaendig.repoUrl !== undefined) data.repoUrl = vollstaendig.repoUrl;
if (vollstaendig.repoOwner !== undefined) data.repoOwner = vollstaendig.repoOwner;
if (vollstaendig.repoName !== undefined) data.repoName = vollstaendig.repoName;
if (vollstaendig.connectionId !== undefined) data.connectionId = vollstaendig.connectionId;
if (vollstaendig.autoSync !== undefined) data.autoSync = vollstaendig.autoSync;
if (Object.keys(data).length === 0) return;
await prisma.repository.update({ where: { id: fuehrend.id }, data });
}
+144 -29
View File
@@ -1,7 +1,9 @@
import type { Project } from "@prisma/client"; import type { Repository } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import { notifyAfterSync } from "@/lib/notify"; import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories";
import type { RepoSnapshotData } from "@/lib/git/types"; import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult { export interface SyncResult {
@@ -11,21 +13,21 @@ export interface SyncResult {
} }
/** /**
* Holt den aktuellen Stand des verknuepften Repositories und schreibt ihn * Holt den aktuellen Stand eines Repositories und schreibt ihn als Snapshot
* als Snapshot in die Datenbank. Fehler werden am Projekt vermerkt statt * in die Datenbank. Fehler werden am Repository vermerkt statt geworfen,
* geworfen, damit ein kaputtes Repo nicht das ganze Dashboard blockiert. * damit ein kaputtes Repo nicht das ganze Dashboard blockiert.
*/ */
export async function syncProject(projectId: string): Promise<SyncResult> { export async function syncRepository(repositoryId: string): Promise<SyncResult> {
const project = await prisma.project.findUnique({ const repository = await prisma.repository.findUnique({
where: { id: projectId }, where: { id: repositoryId },
include: { connection: true }, include: { connection: true },
}); });
if (!project) { if (!repository) {
return { ok: false, message: "Projekt nicht gefunden." }; return { ok: false, message: "Repository nicht gefunden." };
} }
const ref = resolveRepoRef(project); const ref = resolveRepoRef(repository);
if (!ref) { if (!ref) {
return { return {
ok: false, ok: false,
@@ -34,11 +36,11 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
} }
let client; let client;
if (project.connection) { if (repository.connection) {
client = clientForConnection(project.connection); client = clientForConnection(repository.connection);
} else { } else {
// Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL. // Ohne gespeicherte Verbindung: anonymer Zugriff anhand der URL.
const parsed = project.repoUrl ? parseRepoUrl(project.repoUrl) : null; const parsed = repository.repoUrl ? parseRepoUrl(repository.repoUrl) : null;
if (!parsed) { if (!parsed) {
return { return {
ok: false, ok: false,
@@ -54,9 +56,9 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
try { try {
const data = await client.fetchRepo(ref); const data = await client.fetchRepo(ref);
await persistSnapshot(project.id, data); await persistSnapshot(repository, data);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht. // Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(project.id); await notifyAfterSync(repository.id);
return { return {
ok: true, ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
@@ -64,8 +66,8 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
}; };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await prisma.project.update({ await prisma.repository.update({
where: { id: project.id }, where: { id: repository.id },
data: { data: {
syncError: message, syncError: message,
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
@@ -73,24 +75,122 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
syncFailureCount: { increment: 1 }, syncFailureCount: { increment: 1 },
}, },
}); });
await notifyAfterSync(project.id); // Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo
// mit abgelaufenem Token schlaegt sonst stuendlich neu auf.
if (!repository.syncError) {
await recordActivity({
projectId: repository.projectId,
kind: "SYNC_FAILED",
summary: `Abgleich von ${repoTitle(repository)} fehlgeschlagen: ${shorten(message, 100)}`,
});
}
await notifyAfterSync(repository.id);
return { ok: false, message }; return { ok: false, message };
} }
} }
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */ export interface ProjectSyncResult extends SyncResult {
export function resolveRepoRef(project: Project) { /** Ergebnis je Repository – auch dann, wenn nur eines dranhaengt. */
if (project.repoOwner && project.repoName) { repositories: Array<{ id: string; title: string } & SyncResult>;
return { owner: project.repoOwner, name: project.repoName };
} }
if (project.repoUrl) {
const parsed = parseRepoUrl(project.repoUrl); /**
* Gleicht alle Repositories eines Projekts ab, nacheinander.
*
* Nacheinander und nicht parallel: die Repos eines Projekts liegen meist
* beim selben Anbieter, und ein Schwung gleichzeitiger Anfragen ist genau
* das, worauf Rate-Limits reagieren.
*/
export async function syncProject(projectId: string): Promise<ProjectSyncResult> {
const repositories = await prisma.repository.findMany({
where: { projectId },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
if (repositories.length === 0) {
return {
ok: false,
message: "Kein Repository hinterlegt – bitte eines verknuepfen.",
repositories: [],
};
}
const ergebnisse = [];
for (const repository of repositories) {
ergebnisse.push({
id: repository.id,
title: repoTitle(repository),
...(await syncRepository(repository.id)),
});
}
const fehler = ergebnisse.filter((entry) => !entry.ok);
return {
// Erfolgreich nur, wenn kein einziges Repo gescheitert ist. Ein "teilweise
// ok" waere eine Meldung, die man wegklickt, ohne hinzusehen.
ok: fehler.length === 0,
message:
fehler.length === 0
? ergebnisse.length === 1
? ergebnisse[0].message
: `${ergebnisse.length} Repositories abgeglichen.`
: fehler.map((entry) => `${entry.title}: ${entry.message}`).join(" · "),
fetchedAt: new Date().toISOString(),
repositories: ergebnisse,
};
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef(
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
) {
if (repository.repoOwner && repository.repoName) {
return { owner: repository.repoOwner, name: repository.repoName };
}
if (repository.repoUrl) {
const parsed = parseRepoUrl(repository.repoUrl);
if (parsed) return { owner: parsed.owner, name: parsed.name }; if (parsed) return { owner: parsed.owner, name: parsed.name };
} }
return null; return null;
} }
async function persistSnapshot(projectId: string, data: RepoSnapshotData) { /**
* Haelt neue Commits im Verlauf fest.
*
* Der Abgleich laeuft im Zweifel stuendlich, ein Eintrag je Durchlauf waere
* also unbrauchbar. Vermerkt wird nur, wenn sich der letzte Commit tatsaechlich
* geaendert hat – und beim allerersten Abgleich gar nicht: dass ein Repo
* Commits hat, ist keine Neuigkeit.
*/
async function noteNewCommits(
repository: Repository,
vorher: string | null,
data: RepoSnapshotData,
) {
const neuester = data.commits[0]?.sha ?? null;
if (!neuester || !vorher || neuester === vorher) return;
// Wie viele Commits liegen zwischen altem und neuem Stand? Steht der alte
// nicht mehr in der Liste, waren es mehr als eine Seite – dann lieber ohne
// Zahl als mit einer falschen.
const index = data.commits.findIndex((commit) => commit.sha === vorher);
const anzahl = index > 0 ? index : null;
const wo = data.defaultBranch ? `${repoTitle(repository)} · ${data.defaultBranch}` : repoTitle(repository);
const betreff = shorten(data.commits[0]?.message ?? "", 60);
await recordActivity({
projectId: repository.projectId,
kind: "REPO_COMMITS",
summary:
anzahl === null
? `Neue Commits in ${wo}: ${betreff}`
: `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"} in ${wo}: ${betreff}`,
meta: { repositoryId: repository.id, from: vorher, to: neuester, count: anzahl },
});
}
async function persistSnapshot(repository: Repository, data: RepoSnapshotData) {
const latestCommit = data.commits[0] ?? null; const latestCommit = data.commits[0] ?? null;
const latestRelease = const latestRelease =
data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null; data.releases.find((release) => !release.draft && !release.prerelease) ?? data.releases[0] ?? null;
@@ -133,14 +233,20 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
fetchedAt: new Date(), fetchedAt: new Date(),
}; };
// Vor dem Ueberschreiben lesen – danach ist der alte Stand weg.
const vorher = await prisma.repoSnapshot.findUnique({
where: { repositoryId: repository.id },
select: { lastCommitSha: true },
});
await prisma.$transaction([ await prisma.$transaction([
prisma.repoSnapshot.upsert({ prisma.repoSnapshot.upsert({
where: { projectId }, where: { repositoryId: repository.id },
create: { projectId, ...payload }, create: { repositoryId: repository.id, ...payload },
update: payload, update: payload,
}), }),
prisma.project.update({ prisma.repository.update({
where: { id: projectId }, where: { id: repository.id },
data: { data: {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
syncError: null, syncError: null,
@@ -149,5 +255,14 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
repoUrl: data.htmlUrl ?? undefined, repoUrl: data.htmlUrl ?? undefined,
}, },
}), }),
// Das Projekt mit anfassen. Solange die Git-Felder daran hingen, geschah
// das nebenbei; jetzt muss es ausdruecklich passieren, sonst faellt ein
// regelmaessig abgeglichenes Projekt in der Sortierung nach hinten.
prisma.project.update({
where: { id: repository.projectId },
data: { updatedAt: new Date() },
}),
]); ]);
await noteNewCommits(repository, vorher?.lastCommitSha ?? null, data);
} }
+34 -19
View File
@@ -1,8 +1,9 @@
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { pruneActivities } from "@/lib/activity";
import { getAppSettings } from "@/lib/auth/settings"; import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { runStaleCheck } from "@/lib/notify/stale"; import { runStaleCheck } from "@/lib/notify/stale";
import { syncProject } from "@/lib/sync"; import { syncRepository } from "@/lib/sync";
/** /**
* Automatischer Abgleich mit den Git-Systemen. * Automatischer Abgleich mit den Git-Systemen.
@@ -76,19 +77,19 @@ async function tick() {
} }
} }
// Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem // Unabhaengig vom Abgleich: beides ergibt sich aus dem gespeicherten
// gespeicherten Stand, dafuer muss nichts geholt werden. // Stand, dafuer muss nichts geholt werden.
await maybeCheckStale(); await dailyChores();
} catch (error) { } catch (error) {
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
} }
} }
/** /**
* Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt * Was hoechstens einmal am Tag zu tun ist: den Verlauf stutzen und nach
* jemand darauf hoert – sonst waere es reine Datenbanklast. * stillstehenden Projekten sehen.
*/ */
async function maybeCheckStale() { async function dailyChores() {
const settings = await getAppSettings(); const settings = await getAppSettings();
const last = settings.lastStaleCheckAt?.getTime() ?? 0; const last = settings.lastStaleCheckAt?.getTime() ?? 0;
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return; if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
@@ -104,6 +105,17 @@ async function maybeCheckStale() {
data: { lastStaleCheckAt: new Date() }, data: { lastStaleCheckAt: new Date() },
}); });
// Der Verlauf waechst mit jeder Aenderung; einmal am Tag zurueckschneiden
// reicht, um ihn beschraenkt zu halten.
try {
const entfernt = await pruneActivities();
if (entfernt > 0) console.log(`[nebula] Verlauf gestutzt: ${entfernt} Eintraege entfernt.`);
} catch (error) {
console.error("[nebula] Verlauf konnte nicht gestutzt werden:", error);
}
// Die Stillstandspruefung laeuft nur, wenn jemand zuhoert – sonst waere sie
// reine Datenbanklast.
if (zuhoerer === 0) return; if (zuhoerer === 0) return;
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays)); const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
@@ -135,42 +147,45 @@ export async function runAutoSyncPass(
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize)); const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const where: Prisma.ProjectWhereInput = { // Gezaehlt und begrenzt wird seit dem Umbau je Repository, nicht je
// Projekt: sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele
// Anfragen nach sich wie sein Platz im Kontingent hergibt.
const where: Prisma.RepositoryWhereInput = {
autoSync: true, autoSync: true,
// Ohne Repository gibt es nichts abzugleichen. // Ohne Angaben gibt es nichts abzugleichen.
OR: [ OR: [
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] }, { AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
{ repoUrl: { not: null } }, { repoUrl: { not: null } },
], ],
...(settings.autoSyncActiveOnly ...(settings.autoSyncActiveOnly
? { status: { notIn: ["DONE", "ARCHIVED"] } } ? { project: { status: { notIn: ["DONE", "ARCHIVED"] } } }
: {}), : {}),
}; };
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer. // Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
const candidates = await prisma.project.findMany({ const candidates = await prisma.repository.findMany({
where, where,
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }], orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
take: batchSize * 4, take: batchSize * 4,
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true }, select: { id: true, lastSyncedAt: true, syncFailureCount: true },
}); });
const due = candidates.filter((project) => { const due = candidates.filter((repository) => {
if (project.syncFailureCount === 0) return true; if (repository.syncFailureCount === 0) return true;
if (!project.lastSyncedAt) return true; if (!repository.lastSyncedAt) return true;
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt. // Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS); const steps = Math.min(repository.syncFailureCount, MAX_BACKOFF_STEPS);
const wait = intervalMs * 2 ** steps; const wait = intervalMs * 2 ** steps;
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait; const ready = Date.now() - repository.lastSyncedAt.getTime() >= wait;
if (!ready) skipped += 1; if (!ready) skipped += 1;
return ready; return ready;
}); });
const batch = due.slice(0, batchSize); const batch = due.slice(0, batchSize);
for (const project of batch) { for (const repository of batch) {
const result = await syncProject(project.id); const result = await syncRepository(repository.id);
if (result.ok) synced += 1; if (result.ok) synced += 1;
else failed += 1; else failed += 1;
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
+22
View File
@@ -17,6 +17,12 @@ export const projectCreateSchema = z.object({
accent: z.enum(ACCENTS).default("violet"), accent: z.enum(ACCENTS).default("violet"),
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]), tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
favorite: z.boolean().default(false), favorite: z.boolean().default(false),
/**
* Die Git-Felder liegen seit dem Umbau am Repository, bleiben hier aber
* stehen: sie setzen das fuehrende Repository und legen es notfalls an.
* So kommt man mit einem Aufruf zu Projekt und Repo, und bestehende
* Aufrufer – auch der Import – muessen nichts wissen.
*/
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()), repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
@@ -29,6 +35,22 @@ export const projectCreateSchema = z.object({
export const projectUpdateSchema = projectCreateSchema.partial(); export const projectUpdateSchema = projectCreateSchema.partial();
// ── Repositories ─────────────────────────────────────────────
export const repositoryCreateSchema = z.object({
label: z.preprocess(emptyToNull, z.string().trim().max(80).nullable().optional()),
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
autoSync: z.boolean().default(true),
});
export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({
/** Zum Umsortieren; 0 ist das fuehrende. */
position: z.coerce.number().int().min(0).max(50).optional(),
});
// ── Aufgaben ───────────────────────────────────────────────── // ── Aufgaben ─────────────────────────────────────────────────
const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const;
+25 -5
View File
@@ -1,9 +1,11 @@
import type { import type {
ActivityKind,
GitProvider, GitProvider,
Note, Note,
Project, Project,
ProjectStatus, ProjectStatus,
RepoSnapshot, RepoSnapshot,
Repository,
Task, Task,
TaskStatus, TaskStatus,
} from "@prisma/client"; } from "@prisma/client";
@@ -32,22 +34,38 @@ export interface ConnectionSafe {
} }
export interface ConnectionWithCount extends ConnectionSafe { export interface ConnectionWithCount extends ConnectionSafe {
_count?: { projects: number }; _count?: { repositories: number };
}
/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */
export interface RepositoryWithSnapshot extends Repository {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
} }
export interface ProjectListItem extends Project { export interface ProjectListItem extends Project {
connection: ConnectionSafe | null; /** Das fuehrende zuerst – siehe repositoryInclude. */
snapshot: RepoSnapshot | null; repositories: RepositoryWithSnapshot[];
_count: { notes: number; tasks: number }; _count: { notes: number; tasks: number };
/** Nur die erledigten – siehe projectListInclude. */ /** Nur die erledigten – siehe projectListInclude. */
tasks: { id: string }[]; tasks: { id: string }[];
} }
/** Ein Eintrag im Projektverlauf, so wie ihn die Anzeige braucht. */
export interface ActivityEntry {
id: string;
kind: ActivityKind;
summary: string;
createdAt: Date | string;
/** null bedeutet: die Anwendung selbst, etwa der automatische Abgleich. */
user: { username: string; displayName: string | null } | null;
}
export interface ProjectDetail extends Project { export interface ProjectDetail extends Project {
connection: ConnectionSafe | null; repositories: RepositoryWithSnapshot[];
snapshot: RepoSnapshot | null;
notes: Note[]; notes: Note[];
tasks: Task[]; tasks: Task[];
activities: ActivityEntry[];
_count: { notes: number; tasks: number }; _count: { notes: number; tasks: number };
} }
@@ -63,6 +81,7 @@ export interface RepoListEntry {
} }
export type { export type {
ActivityKind,
BranchInfo, BranchInfo,
CiInfo, CiInfo,
CommitInfo, CommitInfo,
@@ -70,6 +89,7 @@ export type {
ProjectStatus, ProjectStatus,
ReleaseInfo, ReleaseInfo,
RepoSnapshot, RepoSnapshot,
Repository,
TagInfo, TagInfo,
Task, Task,
TaskStatus, TaskStatus,