24 Commits
Author SHA1 Message Date
florian.wilde a2547b9022 Merge: Release v1.10.1
CI / pruefen (push) Canceled after 0s
2026-08-18 12:47:12 +00:00
florian.wilde fd633983b8 chore(release): v1.10.1
CI / pruefen (pull_request) Canceled after 0s
Zwei Aenderungen seit 1.10.0, beide bereits auf main: das Kennzeichen fuer
ein verknuepftes Git-Issue auf der Aufgabenkarte (#12) und die Token-Rechte,
Aufruf fuer Aufruf belegt (#10). Dazu die Korrektur am Hinweis unter dem
Token-Feld, der die Rechte fuer Actions nicht nannte - ohne die sieht bei
GitHub niemand einen CI-Zustand.

Verlauf und Versionsnummer wandern zusammen, sonst laufen sie auseinander;
changelog.test.ts haelt das fest. Die package-lock.json stand noch auf 1.2.0
und beschrieb damit ein Paket, das es so nie gab - mitgezogen.

Refs #10
Refs #12
2026-08-18 14:46:35 +02:00
florian.wilde 1b5f3fa1b2 Merge #12: Karte zeigt ein verknuepftes Git-Issue
CI / pruefen (push) Canceled after 0s
2026-08-18 12:39:33 +00:00
florian.wilde 6ee529576d feat(tasks): Karte zeigt, wenn ein Git-Issue daranhaengt
CI / pruefen (pull_request) Canceled after 0s
Die Verknuepfung stand bisher nur im Bearbeiten-Dialog und in der
projektuebergreifenden Uebersicht. Auf dem Brett eines Projekts - dort,
wo man die Aufgaben im Vorbeigehen sieht - war ihr nichts anzusehen.
Wer wissen wollte, welche Aufgabe schon ein Issue hat, musste jede
einzeln aufmachen.

Das Kennzeichen steht vorn in der Fusszeile der Karte, damit sich eine
Spalte danach ueberfliegen laesst, und traegt dasselbe Symbol wie der
Dialog: ein Sachverhalt, ein Zeichen.

Bewusst kein Link. Die Karte steckt in einem <button>, ein <a> darin
waere ungueltiges Markup; der Weg zum Issue fuehrt ueber den Dialog, der
ihn ohnehin anbietet. Die Nummer allein ergibt vorgelesen keinen Sinn,
deshalb steht "Verknuepft mit" als sr-only davor.

Der Fallstrick lag in der Bedingung darueber: die Fusszeile erschien nur
bei Beschreibung, Frist, Wiederholung oder Labels. Gerade eine aus einem
Issue erzeugte Aufgabe hat davon oft nichts - sie waere leer geblieben.

Abzudunkeln, sobald die Aufgabe erledigt ist, war versucht und wieder
verworfen: axe-core rechnete text-violet-300/50 bei 10px als
Kontrastverstoss ab.

Nachgesehen statt angenommen: Brett mit sechs Aufgaben (verknuepft,
unverknuepft, nackt, vollbepackt, erledigt, vierstellige Nummer) auf
1280 und 375 px - kein seitlicher Ueberlauf, axe-core ohne Verstoss.

Refs #12
2026-08-18 14:04:45 +02:00
florian.wilde 77567c06cd Merge #10: Token-Rechte vollstaendig dokumentiert
CI / pruefen (push) Canceled after 0s
2026-08-18 11:51:06 +00:00
florian.wilde e9f01ba8fa docs(git): jeden ausgehenden Aufruf einzeln mit seinem Recht belegen
CI / pruefen (pull_request) Canceled after 0s
Die Faelle A und B beantworteten die Frage auf Ebene der Scopes, nicht
auf Ebene der Aufrufe. Wer nachpruefen wollte, ob ein Recht wirklich
noetig ist, musste die drei Clients selbst lesen.

Jetzt steht in der README je System eine vollstaendige Tabelle: Aufruf,
Zweck, benoetigtes Recht. Die Zuordnung bei GitHub folgt der offiziellen
Berechtigungsuebersicht - dass /tags und /stats/commit_activity unter
Metadata fallen und nicht unter Contents, ist nicht zu erraten. Fuer
GitLab ist mit Quellenangabe belegt, dass es unterhalb von 'api' keinen
Schreibzugriff auf Issues gibt.

Ausserdem war offen, woher die Kommentare an den Issues stammen: Nebula
schreibt keine. Der Schreibzugriff besteht aus genau zwei Aufrufen. Ein
Test klopft dafuer die Clients ab - kaeme ein dritter dazu, wuerde die
Empfehlung 'nimm ein lesendes Token' still falsch, ohne dass jemand
einen Fehler saehe.

Der Hinweis am Eingabefeld nannte Actions nicht, obwohl der CI-Zustand
bei GitHub genau daran haengt.

Refs #10
2026-08-18 12:57:02 +02:00
florian.wilde 7920b6a20f Token-Rechte vollstaendig dokumentiert
CI / pruefen (pull_request) Canceled after 0s
Die Angaben stammten aus der Zeit, als Nebula nur gelesen hat. Seit v1.7.0
legt es Issues an und schliesst sie - wer sich an README oder den Hinweis im
Verbindungsdialog hielt, bekam dabei einen 403.

Erhoben wurde es nicht aus dem Gedaechtnis, sondern aus dem Code: jeder
ausgehende Aufruf in lib/git/gitea.ts, github.ts und gitlab.ts, abgebildet
auf das noetige Recht.

Aufgeschluesselt nach Funktion statt nach System, damit erkennbar ist, was
sich weglassen laesst: der Abgleich allein kommt rein lesend aus, nur die
Issue-Anbindung schreibt.

**GitLab ist der unangenehme Fall.** Es kennt keinen schmalen Schreib-Scope
fuer Issues - dort geht nur "api", also alles, was das Konto darf. Das steht
jetzt ausdruecklich da, samt der Alternative: bei read_api bleiben und auf
die Issue-Anbindung verzichten. Der Abgleich laeuft dann vollstaendig, und
geschlossene Issues werden weiterhin erkannt, nur nicht zurueckgeschrieben.

Ebenfalls festgehalten, was Nebula *nicht* braucht: Schreibrechte auf Code,
Adminrechte, Rechte zum Anlegen von Webhooks. Letzteres ist der Grund, warum
der Haken von Hand eingetragen wird - so bleibt der Token davon frei.

Der Hinweis in lib/git/meta.ts steht direkt am Eingabefeld und ist die
Stelle, die tatsaechlich gelesen wird; er ist deshalb mitgezogen. Dazu ein
Waechter in meta.test.ts: jeder Provider muss in GitClient createIssue und
setIssueState koennen, folglich muss auch jeder Hinweis sagen, was das an
Rechten kostet. Genau diese Kopplung war hier gerissen.

Geprueft: 267 Unit-Tests (6 neue), 36 E2E-Tests, Hinweistext je System gegen
die laufende Anwendung gelesen, axe-core auf 375px und 1280px ohne
Beanstandung.

Refs #10
2026-08-18 12:10:17 +02:00
florian.wilde 6c3f2d5bb6 Merge: Dokumentation auf Stand v1.10.0
CI / pruefen (push) Canceled after 0s
2026-08-18 06:23:04 +00:00
florian.wilde 3617222cc1 docs: alle Markdown-Dokumente auf Stand v1.10.0
CI / pruefen (pull_request) Canceled after 0s
Die Dokumente waren unterschiedlich weit zurueck: FEATURES bei v1.9.0,
README bei v1.8.0, DEPLOYMENT und CLAUDE.md noch bei v1.5.0.

**README.** Die Einleitung nannte weder die projektuebergreifenden Aufgaben
noch Rueckblick, Zeitleiste oder die Issue-Anbindung. In der API-Tabelle
fehlten vier Routen (/api/activity, /api/tasks/:id/issue,
/api/repositories/:id/webhook und der eingehende /api/webhooks/:id); die
Suche war noch als "ueber die eigenen Notizen" beschrieben. Die
Projektstruktur zeigte ein lib/sync.ts, das laengst ein Verzeichnis ist, und
kannte weder tasks/ noch rueckblick/ oder verlauf/.

Unter "Getestet mit" stand Node 26.5.0, tatsaechlich laeuft 24.19.0. Die
Tabelle nennt jetzt den Werkzeugstand mit Datum und trennt ihn von der
Aussage, wann zuletzt gegen echte Git-Systeme geprueft wurde - das war im
Juli und ist seitdem nicht wiederholt worden.

**FEATURES.** Neues Kapitel 2c zu Rueckblick und Zeitleiste, samt der beiden
Entscheidungen, die sonst niemand nachvollziehen kann: Kalendertage in fester
Zeitzone statt "7 x 24 Stunden", und "erledigt" aus dem meta-Feld statt aus
dem Anzeigetext. Das Suchkapitel deckt jetzt Aufgaben mit ab.

**DEPLOYMENT.** Der Abschnitt "Updates" sprang bisher direkt ins Skript,
ohne zu erklaeren, was ein Update ueberhaupt ist - genau daran ist die Frage
"was meinst du mit Deploy?" entstanden. Jetzt steht vorneweg, dass Git und
laufende Instanz zwei Orte sind und woran man sieht, welche Fassung laeuft.
Dazu ein Abschnitt zu Webhooks (setzt Erreichbarkeit voraus, ohne sie geht
aber nichts verloren) und die Sitzungsfristen in der Haertungsliste.

**CLAUDE.md.** Sechs Fallstricke aus den letzten Runden, die sonst beim
naechsten Mal erneut Zeit kosten - allen voran "min-width: auto" bei Flex-
und Grid-Kindern, das hier zweimal zugeschlagen hat. Ausserdem: dass die
feste Zeitzone auch beim Rechnen gilt, dass Dialoge der Bildschirmtastatur
ausweichen muessen, dass ein Ausdrucksindex nur bei Zeichengleichheit
greift (mit EXPLAIN nachsehen), dass ein leeres Migrationsverzeichnis
"migrate diff" abbrechen laesst - und dass eine Ueberlaufmessung gegen
unvollstaendige Testdaten faelschlich gruen meldet.

Geprueft: Querverweise zwischen den Dokumenten aufgeloest, alle in README
und DEPLOYMENT genannten Umgebungsvariablen gegen Code und .env.example
gehalten, API-Tabelle gegen die tatsaechlich vorhandenen Routen.
2026-08-18 08:20:57 +02:00
florian.wilde 43a4c70053 docs(roadmap): Stand auf v1.10.0 gebracht
CI / pruefen (pull_request) Canceled after 0s
Zuletzt angefasst bei v1.7.0. Seitdem sind drei Fassungen und vier Issues
durchgelaufen, ohne dass die Roadmap es mitbekommen haette.

Erledigt und deshalb aus den offenen Abschnitten entfernt:
Wochenrueckblick, globale Zeitleiste und die Suche ueber Aufgaben - alle
drei mit v1.10.0. Webhooks standen noch als Vorschlag drin, obwohl sie seit
v1.7.0 laufen. In der Erledigt-Tabelle fehlten v1.8.0 bis v1.10.0.

Die Nummerierung war ausserdem lueckenhaft: 1.0, dann 1.2 - der Punkt 1.1
war beim Umsetzen herausgeloescht worden. Jetzt wieder fortlaufend, und
1.0 (Rest aus v1.6.0) ist mit 1.3 zusammengefasst, weil beide dasselbe
meinten.

Neu ist ein Abschnitt "Aufgefallen beim Bauen": fuenf Beobachtungen aus der
Arbeit an v1.6.0 bis v1.10.0, die sonst nur in Issue-Kommentaren und im
Chat gestanden haetten - der abgeschnittene Anwendungsname auf schmalen
Anzeigen, die fehlenden Indizes fuer die inzwischen vier
projektuebergreifenden Seiten, der fehlende Projektfilter auf der
Zeitleiste, der offene ASVS-Punkt zu geleakten Passwoertern und die zwei
Fassungen ohne Release-Anker.

Nichts davon ist dringend; festgehalten, damit es nicht verloren geht.
2026-08-18 08:10:32 +02:00
florian.wilde 65a31b7cc9 Merge: v1.10.0
CI / pruefen (push) Canceled after 0s
2026-08-18 05:49:55 +00:00
florian.wildeandClaude Opus 5 ce224bc2a4 chore(release): v1.10.0
CI / pruefen (pull_request) Canceled after 0s
Seit v1.9.0 sind drei Features nach main gegangen, ohne dass die
Versionsnummer mitgezogen waere: Wochenrueckblick (#2), globale Zeitleiste
(#3) und die Suche ueber Aufgaben (#4).

Genau dieselbe Luecke wie vor v1.6.0. Der Test in changelog.test.ts prueft,
dass die oberste Fassung im Verlauf zur package.json passt - er greift aber
nicht, wenn beide gemeinsam zurueckhaengen. Ein Test, der das faende,
muesste den Verlauf gegen die Git-Historie halten; das waere mehr Maschinerie
als der Fehler wert, solange die Freigabe ohnehin von Hand laeuft.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was dazugekommen ist. Nachgesehen
auf 375px und 1280px: Eintraege vollstaendig, keine Beanstandung von
axe-core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:44:16 +02:00
florian.wilde a264eb0dce Merge #4: Suche über Aufgaben
CI / pruefen (push) Canceled after 0s
2026-08-18 05:37:54 +00:00
florian.wilde afe57c4428 Suche ueber Aufgaben
CI / pruefen (pull_request) Canceled after 0s
Zwei Luecken, die im Issue zusammen stehen.

**Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und
Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld
deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im
Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine
zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen.

Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von
letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter
"Erledigte einblenden" gilt nur ohne Suchbegriff.

**Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei,
nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt,
Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit
auch hier - "Rechnung" findet "Rechnungen".

Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der
Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst
greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen
Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe -
Gegenprobe kommt leer heraus.

Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen
haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als
eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und
Schnellsuche -, zeigen sie jetzt.

Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen
die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes
inbegriffen, Stammformen greifen, fremde Konten finden nichts.

Refs #4
2026-08-18 03:39:02 +02:00
florian.wilde 0ef0b3d5ca Merge #3: Globale Zeitleiste
CI / pruefen (push) Canceled after 0s
2026-08-18 01:12:55 +00:00
florian.wilde 9cdd51fba8 Globale Zeitleiste ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
Eine Seite /verlauf, die den Aktivitaetsverlauf fortlaufend ueber alle
Projekte zeigt - ohne Wochengrenze, nach Kalendertagen gegliedert, mit
Nachladen aelterer Eintraege. Erste Seite serverseitig, alles Weitere ueber
/api/activity.

Geblaettert wird per Cursor ueber (createdAt, id), nicht per skip. Der
Verlauf schreibt mehrere Eintraege im selben Augenblick - beim Uebernehmen
von Issues entstehen zehn Zeilen mit identischem Zeitstempel. Ein Offset
ueber eine solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal,
und zwar lautlos. Der zweite Zweig der Bedingung (gleicher Zeitpunkt,
kleinere ID) faengt genau das ab.

Geprueft gegen 144 Eintraege mit absichtlichen Bloecken gleicher Zeitstempel:
15 Seiten, 144 Zeilen, keine Dublette, streng absteigend.

Die Bedingung selbst steht als pruefbare Funktion in lib/timeline, samt
Deckelung der Seitengroesse und Abweisen unbrauchbarer Cursor - eine ID aus
der Abfragezeichenkette geht nicht ungeprueft in die Abfrage.

Damit Rueckblick und Zeitleiste wirklich gleich aussehen, sind Zeile und
Tagesgruppen nach components/ActivityRow gewandert. Bewusst ohne "use
client" und ohne Hooks, damit beides sowohl aus der Server-Komponente
(Rueckblick) als auch aus der Client-Komponente (Zeitleiste) heraus
benutzbar ist. Die Buendelung im Browser ist unbedenklich, weil
tagesschluessel eine feste Zeitzone benutzt - Server und Browser kommen auf
denselben Kalendertag.

Erreichbar ueber die Schnellsuche und vom Wochenrueckblick aus.

Geprueft: 9 neue Unit-Tests zur Cursor-Logik, ein E2E-Test, der die ganze
Liste in Dreierseiten durchblaettert und gegen einen einzelnen grossen
Aufruf haelt, ein E2E-Test fuer die Seite, axe-core auf 375px und 1280px
je vor und nach dem Nachladen ohne Beanstandung.

Refs #3
2026-08-18 01:54:50 +02:00
florian.wilde 5713facc75 Merge #2: Wochenrückblick
CI / pruefen (push) Canceled after 0s
2026-08-17 23:27:19 +00:00
florian.wilde 52f2d0ce8e Wochenrueckblick ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
Eine Seite, die aus dem vorhandenen Aktivitaetsverlauf zusammenfasst, was in
einer Woche ueber alle Projekte hinweg passiert ist: Kennzahlen oben, darunter
die Vorgaenge nach Kalendertagen gebuendelt, neuester Tag zuerst. Blaettern in
frueher Wochen ueber ?woche=-n.

Der Verlauf kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als erste Bedingung.

Zwei Dinge, die sonst still falsch werden:

- **Wochengrenzen** stehen als pruefbare Funktion in lib/review, nicht in der
  Seite. Gerechnet wird ueber Berliner Kalendertage, nicht ueber "sieben mal
  24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
  Stunden. Und im Container laeuft die Anwendung nach UTC, ein Eintrag von
  Sonntagabend fiele sonst in die falsche Woche.
- **"Aufgaben erledigt"** wird am meta-Feld abgelesen (to: "DONE") und nicht
  am Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
  Formulierung; eine Zaehlung daran wuerde beim naechsten umformulierten
  Einzeiler unbemerkt kippen. Eintraege ohne meta zaehlen nicht mit - lieber
  untertreiben als raten.

Damit completeTaskFromIssue dieselbe Grundlage liefert, schreibt es jetzt
ebenfalls from/to ins meta, wie es das Verschieben von Hand schon tut.

Die Zuordnung Symbol/Farbe je Eintragsart lag bisher in ActivityFeed und wird
jetzt von zwei Stellen gebraucht - herausgezogen nach components/activityKinds,
sonst laufen die Kopien beim naechsten neuen Eintragstyp auseinander.

Erreichbar ueber die Schnellsuche und einen Verweis vom Dashboard. Bewusst
kein vierter Punkt in der Kopfleiste - die ist auf dem Telefon schon eng.

Geprueft: 20 neue Unit-Tests (Wochengrenzen inklusive beider Zeitumstellungen,
Jahreswechsel, Zaehlung), ein E2E-Test ueber Aufruf und Blaettern, axe-core auf
375px und 1280px ueber drei Zustaende - auch die leere Woche - ohne
Beanstandung, kein seitlicher Ueberlauf.

Refs #2
2026-08-18 00:55:02 +02:00
florian.wildeandClaude Opus 5 7b60667633 feat(sync): geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab
CI / pruefen (push) Canceled after 0s
Behebt florian.wilde/nebula#1.

Der Rueckweg "Issue geschlossen -> Aufgabe erledigt" gab es seit v1.7.0
schon, aber nur ueber den Webhook. Ist keiner eingerichtet - und am
Repository war keiner eingetragen -, blieb er ganz aus. Damit hing die
Funktion an einer Voraussetzung, die man ihr von aussen nicht ansieht.

Der Abgleich fragt jetzt den Zustand verknuepfter Issues nach und hakt ab,
was in Git geschlossen ist. Beides ergaenzt sich: der Webhook wirkt sofort,
der Abgleich verlaesslich.

Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen
fuehrt, werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht - die
Liste ist gedeckelt, aus dem Fehlen einer Nummer folgt nichts. Deshalb ein
Deckel von 25 Nachfragen je Durchgang.

**Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
Nebula aus fehlgeschlagen ist; ein symmetrischer Abgleich wuerde dann eine
bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
Webhook vorbehalten, der ein echtes "reopened" sieht. Ein geloeschtes Issue
ist ebenfalls kein erledigtes.

Nebenbei eine Unstimmigkeit aus v1.7.0 geradegezogen: der Webhook setzte nur
den Status und liess aus, was die Oberflaeche tut - naechste Fassung einer
wiederkehrenden Aufgabe, Eintrag im Verlauf. Beide Wege gehen jetzt durch
dieselbe Funktion.

Geprueft: 232 Unit-Tests, 31 E2E-Tests. Der neue E2E-Test laeuft als
einziger wirklich nach draussen - er haengt eine Aufgabe an ein seit Jahren
geschlossenes Issue und prueft, dass der Abgleich sie abhakt. Ist GitHub
nicht erreichbar, ueberspringt er sich statt rot zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:20:53 +02:00
florian.wildeandClaude Opus 5 a122187feb feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session
Management und Password Storage. Das meiste stand schon gut da – Token mit
256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder
Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf
Punkte blieben.

**Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat
Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem
2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen
Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt
2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt:
Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel.

**Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer
immer auf den alten Parametern – anheben laesst er sich nur dort, wo das
Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p,
nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu
gehasht werden.

**Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort-
pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423),
deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401).
Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das
Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten
verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer
zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst
hingenommen.

**Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein
abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde.
ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS
einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist –
bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt.

**Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-".
Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist –
eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides
kostet einmalig eine neue Anmeldung.

Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und
Passwoerter, die den Benutzernamen enthalten, werden abgelehnt.
Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab.

Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern)
und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes
Konto gegen die auf ein erfundenes haelt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:38:05 +02:00
florian.wildeandClaude Opus 5 1fe41ac2a5 fix(ui): Projektseite passt wieder in die Fensterbreite
CI / pruefen (push) Canceled after 0s
Auf dem Telefon war die Projektseite doppelt so breit wie der Bildschirm:
die Kopfleiste endete auf halber Strecke, "Jetzt synchronisieren" und der
Notiz-Knopf lagen ausserhalb.

Ursache sind die beiden Rasterspalten. Ein Rasterkind hat "min-width: auto"
und weigert sich damit, unter die Inhaltsbreite seines breitesten Kindes zu
schrumpfen. Breitestes Kind ist die Heatmap mit ueber 700px – sie zog die
Spalte auf und mit ihr die ganze Seite. Das overflow-x-auto um sie herum
kam gar nicht zum Zug, weil der Elter ihm die volle Breite zugestand.

Mit min-w-0 an beiden Spalten greift es wieder: gemessen auf 390px liegen
scrollWidth und clientWidth jetzt gleichauf, vorher 811 zu 390.

Warum das beim Umbau der mobilen Ansicht nicht auffiel: die Messung lief
gegen ein Projekt ohne Repository, ohne Snapshot und ohne README. Ohne
Heatmap gibt es nichts, was die Spalte aufziehen koennte. Nachgestellt
wurde das jetzt mit einem vollstaendigen Projekt – Snapshot, README mit
Codeblock und Tabelle, Notizen, Aufgaben.

Ein Regressionstest dafuer fehlt noch: die E2E-Tests legen Projekte ueber
die API an und kommen ohne echten Abgleich zu keinem Snapshot, also auch
zu keiner Heatmap. Ein Test ohne sie wuerde die Sicherheit vortaeuschen,
ohne den Fall zu treffen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 07:32:04 +02:00
florian.wildeandClaude Opus 5 c270038edd feat(git): Webhooks fuer Push und Issues, v1.7.0
CI / pruefen (push) Canceled after 0s
Ein Push aktualisiert den Stand sofort, statt bis zum naechsten Durchlauf
des Zeitgebers zu warten; ein geschlossenes Issue hakt seine Aufgabe ab.
Der Zeitplan bleibt als Rueckfalllosung.

Der Endpunkt ist der einzige ohne Anmeldung, der schreibt – entsprechend
eng gefasst:

- Ausgewiesen wird sich mit einer Signatur ueber den Rumpf. GitHub und
  Gitea rechnen HMAC-SHA256, GitLab schickt nur den Token im Klartext;
  dessen Verfahren ist schwaecher, mehr gibt die Plattform nicht her.
  Verglichen wird durchweg zeitkonstant.
- Geprueft wird gegen den **rohen** Rumpf. Durch JSON.parse und
  JSON.stringify gedreht kaeme eine andere Zeichenfolge heraus und die
  Signatur passte nie.
- Der Rumpf ist nie Quelle fuer Inhalte. Wer das Geheimnis kennt, kann eine
  Aufgabe auf- oder zumachen und einen Abgleich ausloesen – keine
  beliebigen Daten einschleusen.
- Ratenbegrenzung je Repository, Deckel auf die Rumpfgroesse, und ein
  unbekanntes Repository antwortet wie ein nicht eingerichtetes: 404,
  damit die ID nicht bestaetigt wird.
- Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler
  brachte das Git-System zum Wiederholen und irgendwann zum Abschalten.

Das Geheimnis liegt verschluesselt am Repository, wie die Zugangstoken, und
ist genau einmal zu sehen. Damit es nicht doch nach draussen wandert, laedt
repositoryInclude die Repositories nicht mehr per include, sondern ueber
eine ausdrueckliche Feldliste: ein include haette das neue Feld still in
jede Projektantwort und jede Seite mitgenommen. Ob ein Haken eingerichtet
ist, holt die Projektseite getrennt.

Die Migration legt zwei nullbare Spalten an, mehr nicht; der Gegenlauf von
migrate diff kommt leer heraus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:35:29 +02:00
florian.wildeandClaude Opus 5 e1d435f10a feat(tasks): Aufgabe als Git-Issue anlegen, Zustand wandert mit
Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und
sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der
Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung.

Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in
denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den
Rumpf kommt ein Rueckverweis auf das Projekt.

Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System
geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst
verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das
Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht
erreichbares Git-System darf das Abhaken nicht verhindern.

Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt
vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein
Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die
Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die
dort niemand pflegt.

Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber
die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson –
durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf
eine interne Adresse naehme den Rumpf und den Token im Header mit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:35:11 +02:00
florian.wildeandClaude Opus 5 6a7365946b fix(ui): Dialoge weichen der Bildschirmtastatur aus
Auf dem Telefon verdeckte die Tastatur den halben Dialog – bei der
Schnellerfassung genau das Eingabefeld, um das es geht.

Ursache ist der Unterschied zwischen Layout- und Sichtfenster: die Tastatur
schiebt sich darueber, ohne dass sich fuer die Seite etwas aendert.
"position: fixed" und "100vh" beziehen sich weiter auf das volle Fenster,
ein mittig stehender Dialog liegt damit zur Haelfte darunter.

Der Dialog richtet sich jetzt nach window.visualViewport – Hoehe und oberer
Rand kommen von dort und werden bei jeder Aenderung nachgefuehrt. Weil der
Behaelter scrollbar ist, holt der Browser das fokussierte Feld dann von
selbst ins Bild. Das gilt fuer alle Dialoge, nicht nur die Schnellerfassung.

Dazu, weil es beim Nachsehen auffiel: Aufgaben lassen sich in der
Uebersicht direkt bearbeiten. Bisher fuehrte von dort nur der Weg ins
Projekt, und wer eine Faelligkeit aendern wollte, musste die Aufgabe im
Brett wiederfinden. Der Dialog nimmt dafuer eine schlankere Form entgegen
als den vollstaendigen Prisma-Datensatz.

Geprueft mit einem nachgestellten Sichtfenster: Eingabefeld und Absende-
knopf liegen bei 375x667 und aufgeklappter Tastatur vollstaendig im freien
Bereich. Vorher lagen beide darunter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 00:34:56 +02:00
70 changed files with 4909 additions and 235 deletions
+6 -1
View File
@@ -69,9 +69,14 @@ ADMINER_PORT="127.0.0.1:8080"
# und Passkeys sind davon nicht betroffen. # und Passkeys sind davon nicht betroffen.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert" APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen. # Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet
# und nicht verlaengerbar.
SESSION_TTL_DAYS="30" SESSION_TTL_DAYS="30"
# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben.
# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab.
SESSION_IDLE_HOURS="8"
# ── Benachrichtigungen ─────────────────────────────────────── # ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der # ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet. # Anwendung unter "Mein Konto" eingerichtet.
+55 -3
View File
@@ -64,6 +64,31 @@ SSRF-Schutz (`lib/security/ssrf.ts`).
- **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst - **Datumsangaben** über `lib/utils` formatieren (feste Zeitzone), sonst
weichen Server-Render und Hydration voneinander ab. Relative Zeiten weichen Server-Render und Hydration voneinander ab. Relative Zeiten
bekommen `suppressHydrationWarning`. bekommen `suppressHydrationWarning`.
- **Die feste Zeitzone gilt auch beim *Rechnen*, nicht nur beim Formatieren.**
Im Container läuft die Anwendung nach UTC; „welcher Kalendertag" und
„welche Woche" kippen dort abends um einen Tag. `lib/tasks` (`dueBucket`)
und `lib/review` (`wocheUm`) rechnen deshalb über Berliner Kalendertage.
Und eine Woche ist **nicht** „7 × 24 Stunden" — bei einer Zeitumstellung
hat sie 167 oder 169.
- **`min-width: auto` bei Flex- und Grid-Kindern.** Hat hier zweimal
zugeschlagen: ein Kind weigert sich, unter die Inhaltsbreite seines
breitesten Kindes zu schrumpfen, und drückt stattdessen die Nachbarn aus
dem Bild. Einmal war es ein Sortierfeld mit `min-width`, das „Neues
Projekt" hinausschob; einmal die Heatmap, die die ganze Projektseite
doppelt so breit machte. Heilmittel ist beide Male `min-w-0` am Kind —
ohne das greift auch ein `overflow-x-auto` darin nicht.
- **`body { overflow-x: hidden }` versteckt genau diese Fehler.** Es steht in
`globals.css` als Notnagel; was es abschneidet, ist auf dem Telefon
schlicht nicht mehr erreichbar. Ursachen gehören in die Komponente.
- **Dialoge müssen der Bildschirmtastatur ausweichen.** Sie verkleinert nur
das *sichtbare* Fenster, nicht das Layoutfenster — `position: fixed` und
`100vh` beziehen sich weiter auf das volle. `components/ui/Modal.tsx` hängt
dafür an `window.visualViewport`; wer ein eigenes Overlay baut, braucht
dasselbe.
- **Blättern über Cursor, nicht über `skip`.** Der Aktivitätsverlauf schreibt
mehrere Zeilen im selben Augenblick; ein Offset über so eine Grenze hinweg
überspringt oder verdoppelt lautlos. Muster in `lib/timeline.ts`:
`(createdAt, id)` als Cursor, Sortierung identisch zur Bedingung.
## Migrationen ## Migrationen
@@ -83,9 +108,24 @@ vergleicht mit der *laufenden* Datenbank. Ist eine eben erzeugte Migration
dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch dort noch nicht eingespielt, steht ihr Inhalt in der nächsten gleich noch
einmal. einmal.
**Die erzeugte SQL vor dem Anwenden lesen.** Der Volltextindex **Das Zielverzeichnis vorher *nicht* anlegen.** Ein leerer Ordner unter
`Note_fulltext_idx` ist ein Ausdrucksindex, den Prisma nicht kennt (er wird `prisma/migrations/` lässt `--from-migrations` mit `P3015` abbrechen
bislang in Ruhe gelassen, aber das ist nicht garantiert). („Could not find the migration file"). Erst die SQL woandershin erzeugen,
dann Ordner anlegen und hineinkopieren.
**Die erzeugte SQL vor dem Anwenden lesen.** Die Volltextindizes
`Note_fulltext_idx` und `Task_fulltext_idx` sind Ausdrucksindizes, die Prisma
nicht kennt (sie werden bislang in Ruhe gelassen, aber das ist nicht
garantiert).
**Ein Ausdrucksindex greift nur bei Zeichengleichheit.** Weicht der Ausdruck
in der Abfrage auch nur in einem `coalesce` vom Index ab, rechnet Postgres
`to_tsvector` je Zeile neu — ohne Fehlermeldung. Nachsehen statt annehmen:
```bash
docker exec nebula-db psql -U nebula -d <db> -c "SET enable_seqscan = off;
EXPLAIN (COSTS OFF) SELECT …" # muss einen Bitmap Index Scan zeigen
```
**Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den **Datenmigrationen von Hand schreiben.** `migrate diff` kennt nur den
Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen. Zielzustand: Spalten, die woandershin wandern, würde es ersatzlos löschen.
@@ -103,6 +143,18 @@ 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.
**Seitlichen Überlauf misst man.** `document.documentElement.scrollWidth`
gegen `clientWidth` auf 375 px — das findet in Sekunden, was beim Ansehen
durchrutscht. Ein Skript, das dabei nur die *obersten* Verursacher meldet
(Eltern, die selbst schon überlaufen, herausfiltern), ist deutlich brauchbarer
als eine Liste aller betroffenen Knoten.
**Die Testdaten müssen die Bausteine enthalten, um die es geht.** Eine
Messung gegen ein Projekt ohne Repository, ohne Snapshot und ohne README
bescheinigte hier einmal „alles in Ordnung", während die echte Projektseite
doppelt so breit war wie der Bildschirm. Erst mit vollständigen Daten
nachgestellt trat der Fehler zutage.
**Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als **Barrierefreiheit misst man, statt sie zu behaupten.** `axe-core` liegt als
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
+42 -1
View File
@@ -283,6 +283,23 @@ Zurückspielen:
## Updates ## Updates
Es sind zwei getrennte Orte, und das ist der Punkt, an dem es leicht
durcheinandergeht:
| | Was dort liegt |
| --- | --- |
| **Das Git-Repository** | Der Quellcode. Dorthin wird entwickelt. |
| **Diese Instanz** | Die laufende Anwendung, die du im Browser benutzt. |
Ein Commit im Git ändert am laufenden Nebula **nichts**. Der Server holt sich
von allein nichts ab; er betreibt weiter die Fassung, die dort zuletzt gebaut
wurde. Ein Update ist der Schritt dazwischen: holen, bauen, umschalten.
**Woran du siehst, was gerade läuft:** die Versionsnummer unten in der
Fußzeile. Ein Klick darauf öffnet den Änderungsverlauf. Steht dort eine
ältere Nummer als im Git, fehlen dir die neueren Funktionen — sie sind
geschrieben, aber noch nicht auf diesem Server.
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
Reihenfolge und bricht ab, sobald ein Schritt scheitert: Reihenfolge und bricht ab, sobald ein Schritt scheitert:
@@ -308,7 +325,7 @@ nennt den Weg zurück.
```bash ```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron ./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung ./scripts/update.sh --ref v1.9.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull ./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
``` ```
@@ -354,6 +371,23 @@ docker system df
docker image prune -f # alte Abbilder nach mehreren Updates docker image prune -f # alte Abbilder nach mehreren Updates
``` ```
### Webhooks
Der Abgleich holt den Stand in seinem Takt. Wer ihn **sofort** haben will —
ein Push ist durch, ein Issue wurde geschlossen —, trägt im Git-System einen
Webhook ein. Nebula erzeugt URL und Geheimnis am Repository (Funksymbol im
Git-Bereich der Projektseite), eingetragen wird der Haken von Hand.
Voraussetzung ist, dass **dein Git-System diese Instanz erreicht**. Hinter
dem Reverse Proxy ist das gegeben; in einem abgeschotteten Netz nicht. Der
Endpunkt liegt unter `/api/webhooks/<repositoryId>` und ist der einzige ohne
Anmeldung — ausgewiesen wird sich über eine Signatur, die gegen das
hinterlegte Geheimnis geprüft wird.
**Ohne Webhook geht nichts verloren.** Der Abgleich prüft den Zustand
verknüpfter Issues ohnehin mit und hakt erledigte Aufgaben nach. Der Webhook
macht es nur sofort statt beim nächsten Durchlauf.
### Automatischer Abgleich ### Automatischer Abgleich
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen → Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
@@ -383,6 +417,13 @@ Damit du weißt, worum du dich **nicht** kümmern musst:
Zugriffe; Kontosperre nach acht Fehlversuchen. Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und - **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
verlassen den Server nie — auch nicht im Export. verlassen den Server nie — auch nicht im Export.
- **Sitzungen** laufen nach 8 Stunden ohne Nutzung ab und spätestens nach
30 Tagen (`SESSION_IDLE_HOURS`, `SESSION_TTL_DAYS`). In der Datenbank liegt
nur der Hash des Tokens; das Cookie ist `HttpOnly`, `SameSite=Strict` und
bei HTTPS zusätzlich mit dem Präfix `__Host-`.
- **Passwörter** werden mit scrypt nach der Empfehlung des OWASP Password
Storage Cheat Sheet gehasht (N = 2^17). Rechnet mit rund einer halben
Sekunde je Anmeldung — das ist gewollt.
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht, - Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert. und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
+217 -8
View File
@@ -22,8 +22,19 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
### 0.2 Anmeldung ### 0.2 Anmeldung
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden - **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende
abgelehnt. Hashing mit scrypt. Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`),
durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält.
**Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu
`Passwort1!` führen statt zu Entropie.
- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat
Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben
also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand
gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt.
- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt,
deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort
stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit
(`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen.
- **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.
@@ -134,12 +145,17 @@ dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte - **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
oder **Kanban**. oder **Kanban**.
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten - **Volltextsuche über Notizen und Aufgaben.** Die Suche im Board arbeitet auf den
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie geladenen Projekten (Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen und Aufgaben
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit liegen dort nicht vor — sie mitzuladen würde die Seite aufblähen. Stattdessen sucht
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene Postgres serverseitig mit seiner deutschen Textsuche, entprellt ab zwei Zeichen.
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich Treffer erscheinen als zwei eigene Listen mit hervorgehobener Fundstelle, die
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks". betroffenen Projekte werden zusätzlich eingeblendet. Deutsche Stammformen greifen:
„Webhook" findet auch „Webhooks", „Rechnung" auch „Rechnungen".
Beide Seiten haben je einen **Ausdrucksindex** (`Note_fulltext_idx`,
`Task_fulltext_idx`). Der Ausdruck darin muss Zeichen für Zeichen dem in der Abfrage
entsprechen, sonst greift er nicht und Postgres rechnet `to_tsvector` je Zeile neu.
- **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.
@@ -239,6 +255,10 @@ Blockiert · Erledigt**.
Fälligkeitsdatum und Labels. Fälligkeitsdatum und Labels.
- **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen - **Fälligkeiten** färben sich mit: überfällig rot, heute gelb, in den nächsten Tagen
gedämpft. gedämpft.
- **Hängt ein Git-Issue daran**, trägt die Karte dessen Nummer — vorn in der Fußzeile,
vor Beschreibung, Wiederholung, Frist und Labels, damit sich eine Spalte danach
überfliegen lässt. Bewusst kein Link: der Weg zum Issue führt über den Dialog (4.6),
weil ein `<a>` innerhalb der anklickbaren Karte ungültiges Markup wäre.
- 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.
@@ -330,6 +350,54 @@ Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
--- ---
## 2c. Rückblick und Verlauf
Zwei Sichten auf denselben Aktivitätsverlauf, über alle Projekte hinweg. Beide teilen
sich Zeilendarstellung und Tagesgruppierung (`components/ActivityRow`) — zwei Kopien
wären beim nächsten Feinschliff auseinandergelaufen.
Der Verlauf kennt keinen Besitzer; die Trennung läuft über das Projekt
(`project: { ownerId }` als erste Bedingung).
### Wochenrückblick
`/rueckblick` beantwortet „was ist diese Woche passiert". Oben Kennzahlen — Vorgänge,
berührte Projekte, erledigte und neue Aufgaben —, darunter die Vorgänge nach
Kalendertagen gebündelt, neuester Tag zuerst. Über `?woche=-n` geht es bis zu 25 Wochen
zurück; weiter lohnt nicht, weil der Verlauf ohnehin nur 200 Einträge je Projekt hält.
**Gerechnet wird auf Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
„sieben mal 24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
Stunden. Und im Container läuft die Anwendung nach UTC — ein Eintrag von Sonntagabend
fiele sonst in die falsche Woche. Das Fach bestimmt der **Server**, damit Server-Render
und Hydration nicht auseinanderlaufen.
**„Aufgaben erledigt" wird am `meta`-Feld abgelesen** (`to: "DONE"`), nicht am
Anzeigetext. Der Text ist zum Lesen da und ändert sich mit der Formulierung; eine
Zählung daran würde beim nächsten umformulierten Einzeiler unbemerkt kippen. Einträge
ohne `meta` zählen nicht mit — lieber untertreiben als raten.
### Globale Zeitleiste
`/verlauf` zeigt denselben Verlauf **fortlaufend**: ohne Wochengrenze, ohne Fazit, mit
„Ältere laden". Die erste Seite kommt serverseitig mit, alles Weitere über
`/api/activity`.
**Geblättert wird per Cursor** über `(createdAt, id)`, nicht per `skip`. Der Verlauf
schreibt mehrere Einträge im selben Augenblick — beim Übernehmen von Issues entstehen
zehn Zeilen mit identischem Zeitstempel. Ein Offset über eine solche Grenze hinweg
überspringt Zeilen oder zeigt sie zweimal, und zwar lautlos. Der zweite Zweig der
Bedingung (gleicher Zeitpunkt, kleinere ID) fängt genau das ab.
Nachgerechnet gegen 144 Einträge mit absichtlichen Blöcken gleicher Zeitstempel: 15
Seiten, 144 Zeilen, keine Dublette, streng absteigend.
Beide Seiten sind über die Schnellsuche erreichbar, der Rückblick zusätzlich über einen
Verweis vom Dashboard. Bewusst **kein** weiterer Punkt in der Kopfleiste — die ist auf
dem Telefon schon eng.
---
## 3. Notizen ## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text. - **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
@@ -421,6 +489,40 @@ Firmen-Gitea nebeneinander.
Kurzbeschreibung. Kurzbeschreibung.
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten. - **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
#### Nötige Token-Rechte
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: `POST …/issues`
beim Anlegen und `PATCH`/`PUT …/issues/{nr}` beim Schließen oder Wiederöffnen (4.6). Wer
darauf verzichtet, kommt mit einem rein lesenden Token aus.
| System | Nur Abgleich | Zusätzlich für die Issue-Anbindung |
| --- | --- | --- |
| GitHub | *Metadata*, *Contents*, *Issues*, *Pull requests*, *Actions* — je lesend | *Issues: Read and write* |
| GitLab | `read_api` | `api` |
| Gitea / Forgejo | `read:repository`, `read:issue`, `read:user` | `write:issue` |
Welcher Aufruf welches Recht verlangt, steht Zeile für Zeile in der
[README](README.md#welche-rechte-der-token-braucht) — dort ist jeder ausgehende Aufruf
der drei Clients aufgeführt.
**GitLab kennt keinen schmalen Schreib-Scope.** `api` erlaubt alles, was das Konto darf,
Code eingeschlossen. Wem das zu weit geht, bleibt bei `read_api`: der Abgleich läuft
vollständig, geschlossene Issues werden weiterhin erkannt — nur schreibt Nebula dann
nicht zurück.
**Nicht verlangt** werden Schreibrechte auf Code, Adminrechte und Rechte zum Anlegen von
Webhooks. Der Haken wird von Hand im Git-System eingetragen (4.7); das erspart dem Token
genau die Administrationsrechte, die er sonst bräuchte.
**Kommentare schreibt Nebula nie** — weder beim Anlegen noch beim Schließen. In den
Rumpf des angelegten Issues wandert die Aufgabenbeschreibung plus eine Zeile Rückverweis
auf das Projekt; danach bleibt der Text unangetastet. Was sonst an Kommentaren und
Labels an einem Issue hängt, kommt aus der Weboberfläche oder von einem anderen Werkzeug.
Ein fehlendes Recht endet in einem `403` mit „Token hat nicht die noetigen Scopes",
sichtbar am Repository auf der Projektseite. Klein anfangen und gezielt nachlegen ist
deshalb ohne Risiko.
### 4.3 Was beim Sync geholt wird ### 4.3 Was beim Sync geholt wird
| Bereich | Details | | Bereich | Details |
@@ -503,6 +605,103 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen - **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab. parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
### 4.6 Aufgabe ↔ Git-Issue
Die Verbindung geht in beide Richtungen — mit einer bewussten Grenze.
**Nebula → Git.** Ein Knopf im Aufgabendialog legt zur Aufgabe ein Issue im
verknüpften Repository an. Nummer und Adresse landen an der Aufgabe (`sourceIssue`,
`sourceUrl`, `sourceRepositoryId` — dieselben Felder, die die Übernahme aus Issues seit
v0.9.0 füllt, samt Eindeutigkeit je Repository gegen Dubletten). In den Rumpf kommt ein
Rückverweis auf das Projekt.
Ausdrücklich **per Knopf und nie nebenbei**: was in ein fremdes System geschrieben wird,
soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledigt schließt
das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe
trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern.
**Git → Nebula** auf zwei Wegen, die sich ergänzen:
- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz
von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7).
- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand
verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der
Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein
Haken einmal ausfällt.
Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt,
werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen
Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang
fragt der Abgleich höchstens 25 Issues nach.
**Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse:
ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen
ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder
aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“
sieht.
Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen.
In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der
Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der
Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan,
sondern das Git-System gemeldet).
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
außen geht deshalb nur die Grenze um „erledigt“; ein Rundlauf verliert *In Arbeit* und
*Blockiert*. Labels wie `nebula:doing` wären die Alternative, würden aber den
Issue-Tracker mit Zuständen füllen, die dort niemand pflegt. Die feine Abstufung bleibt
in Nebula.
Eine echte beidseitige Synchronisation mit Konfliktauflösung gibt es **nicht** — das ist
der klassische Ort für stillen Datenverlust.
**Kein Widerspruch zu „keine eigenen Git-Operationen“:** Issues laufen über die HTTP-API
der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`, SSRF-Schutz
inklusive). Kein Klon, kein Arbeitsverzeichnis. Schreibende Aufrufe folgen dabei
**keinen Umleitungen** — ein 307 auf eine interne Adresse nähme den Rumpf und den Token
im Header mit.
### 4.7 Webhooks
Ein Push aktualisiert den Stand sofort, statt bis zum nächsten Durchlauf des Zeitgebers
zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als
Rückfalllösung bestehen.
Einzurichten am Repository (Funksymbol im Git-Bereich): Nebula erzeugt URL und
Geheimnis, eingetragen wird der Haken im Git-System von Hand. Über die API anzulegen
wäre machbar, verlangte aber Administrationsrechte am Repository — deutlich mehr, als
Nebula sonst braucht.
Das Geheimnis ist **genau einmal** zu sehen; danach liegt es verschlüsselt in der
Datenbank, wie die Zugangstoken, und verlässt den Server nicht mehr.
| System | Ausweis | Kopfzeile |
| --- | --- | --- |
| GitHub | HMAC-SHA256 über den Rumpf | `X-Hub-Signature-256: sha256=…` |
| Gitea | dieselbe Rechnung, ohne Präfix | `X-Gitea-Signature` (ältere: `X-Hub-Signature-256`) |
| GitLab | das Geheimnis im Klartext | `X-Gitlab-Token` |
GitLabs Verfahren ist schwächer: es beweist nur, dass der Absender das Geheimnis kennt,
nicht dass der Rumpf unverändert ist. Mehr gibt die Plattform nicht her; verglichen wird
trotzdem zeitkonstant.
Geprüft wird gegen den **rohen** Rumpf — durch `JSON.parse` und `JSON.stringify` gedreht
käme eine andere Zeichenfolge heraus und die Signatur passte nie.
Was der Endpunkt bewusst **nicht** tut: den Rumpf als Quelle für Inhalte benutzen. Titel,
Beschreibung und Zustand kommen weiter aus Nebula beziehungsweise beim nächsten Abgleich
aus der API. Wer das Geheimnis kennt, kann also höchstens eine Aufgabe auf- oder zumachen
und einen Abgleich auslösen — keine beliebigen Daten einschleusen.
Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brächte das
Git-System zum Wiederholen und irgendwann zum Abschalten des Hakens.
Der Endpunkt ist der einzige ohne Anmeldung, der schreibt. Deshalb: Ratenbegrenzung je
Repository (60/Minute), Deckel auf die Rumpfgröße, und ein unbekanntes Repository
antwortet wie ein nicht eingerichtetes — mit 404, damit die ID nicht bestätigt wird.
--- ---
## 4a. Export, Import und Sicherung ## 4a. Export, Import und Sicherung
@@ -688,6 +887,16 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`. `GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und - **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
WebAuthn-Optionen. WebAuthn-Optionen.
- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die
Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden
und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab
Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`).
Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue
aus.
- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und
dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen,
dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht
überschreiben und keine Sitzung unterschieben.
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus - Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`). `APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl - Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
+163 -18
View File
@@ -22,6 +22,12 @@ Jedes Projekt bekommt einen **Status** (von der rohen Idee bis „Fertig"), beli
Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab** Branches, Tags, Commits, Releases und offene Issues/PRs — egal ob **GitHub**, **GitLab**
oder eine selbst gehostete **Gitea**-Instanz. oder eine selbst gehostete **Gitea**-Instanz.
Neben dem Projektbrett gibt es **Aufgaben über alle Projekte hinweg** (nach Fälligkeit
geordnet), einen **Wochenrückblick** und eine **fortlaufende Zeitleiste** aus dem
Aktivitätsverlauf. Aufgaben lassen sich als **Git-Issue** anlegen; wird das Issue
geschlossen, hakt Nebula die Aufgabe ab — sofort per **Webhook** oder verzögert beim
nächsten Abgleich.
Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula Die Anmeldung läuft über **Benutzername und Passwort oder Passkey** (WebAuthn). Nebula
kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in kennt zwei Betriebsarten: **Einzelbetrieb** für ein Konto oder **Mehrbenutzerbetrieb**, in
dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in dem jede Person ihren eigenen, streng getrennten Bereich hat. Umschalten geht jederzeit in
@@ -118,7 +124,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` | | `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` | | `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** | | `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` | | `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` |
| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` |
| `PORT` | Port der Anwendung | `3000` | | `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` | | `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
@@ -166,14 +173,8 @@ Nebula auffindbare Credentials nutzt.
## Git-Verbindung einrichten ## Git-Verbindung einrichten
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen: 1. Im Git-System einen **Personal Access Token** erzeugen — mit den Rechten aus dem
Abschnitt [Welche Rechte der Token braucht](#welche-rechte-der-token-braucht).
| System | Scopes |
| --- | --- |
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
| GitLab | `read_api` (alternativ `read_repository`) |
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen: 2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token. System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück. 3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
@@ -185,6 +186,132 @@ Nebula auffindbare Credentials nutzt.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline. Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit. Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Welche Rechte der Token braucht
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: wenn du eine
Aufgabe als Issue anlegst, und wenn eine Aufgabe auf `DONE` wandert und Nebula das
verknüpfte Issue schließt. Alles andere sind `GET`-Aufrufe. Deshalb lohnt es, zwei Fälle
zu unterscheiden:
**Fall A — nur Abgleich.** Branches, Tags, Commits, Releases, CI-Zustand, README,
Heatmap, offene Issues und PRs anzeigen. Rein lesend.
**Fall B — zusätzlich die Issue-Anbindung.** Eine Aufgabe als Issue anlegen; wird das
Issue geschlossen, hakt Nebula die Aufgabe ab und schließt sie umgekehrt mit.
| System | Fall A (nur lesen) | Fall B zusätzlich |
| --- | --- | --- |
| **GitHub** | Fine-grained Token: *Metadata: Read*, *Contents: Read*, *Issues: Read*, *Pull requests: Read*, *Actions: Read* | *Issues: **Read and write*** |
| **GitLab** | `read_api` | `api` — **es gibt nichts dazwischen** |
| **Gitea / Forgejo** | `read:repository`, `read:issue`, `read:user` | `write:issue` |
#### Jeder Aufruf einzeln
Die folgenden Tabellen sind vollständig: mehr als das ruft Nebula nicht nach draußen.
Sie sind aus `src/lib/git/github.ts`, `gitlab.ts` und `gitea.ts` erhoben — wer prüfen
will, ob hier etwas fehlt, greppt dort nach `getJson`, `getJsonSafe` und `sendJson`.
**GitHub** — `https://api.github.com`, Header `Authorization: Bearer …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /rate_limit` | Verbindungstest | keins |
| `GET /user` | Kontoname beim Testen zurückmelden | keins |
| `GET /user/repos` | Auswahlliste hinter **Repos laden** | Metadata: Read |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | Metadata: Read |
| `GET …/languages` | Sprachverteilung | Metadata: Read |
| `GET …/tags` | Tags | Metadata: Read |
| `GET …/stats/commit_activity` | Commit-Heatmap über ein Jahr | Metadata: Read |
| `GET …/branches` | Branch-Liste | Contents: Read |
| `GET …/commits` | die letzten 100 Commits | Contents: Read |
| `GET …/releases` | Releases | Contents: Read |
| `GET …/readme` | README-Vorschau | Contents: Read |
| `GET …/pulls?state=open` | offene Pull Requests | Pull requests: Read |
| `GET …/issues?state=open` | offene Issues | Issues: Read |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | Issues: Read |
| `GET …/actions/runs` | CI-Zustand des Default-Branch | Actions: Read |
| `POST …/issues` | **Aufgabe als Issue anlegen** | Issues: Read and write |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | Issues: Read and write |
Dass `/tags` und `/stats/commit_activity` unter *Metadata* fallen und nicht unter
*Contents*, ist nicht zu erraten — es steht so in
[GitHubs Berechtigungsübersicht](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens).
**GitLab** — `{Basis-URL}/api/v4`, Header `PRIVATE-TOKEN: …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /projects?per_page=1` | Verbindungstest | `read_api` |
| `GET /user` | Kontoname beim Testen zurückmelden | `read_user` (in `read_api` enthalten) |
| `GET /projects?membership=true` | Auswahlliste hinter **Repos laden** | `read_api` |
| `GET /projects/{id}?statistics=true` | Stammdaten, Default-Branch, Sichtbarkeit | `read_api` |
| `GET …/repository/branches` · `/tags` · `/commits` | Snapshot und Heatmap | `read_api` |
| `GET …/repository/tree` · `/repository/files/{datei}` | README (zwei Aufrufe, GitLab hat keinen README-Endpunkt) | `read_api` |
| `GET …/releases` · `/languages` | Releases, Sprachverteilung | `read_api` |
| `GET …/merge_requests?state=opened` | offene Merge Requests | `read_api` |
| `GET …/issues?state=opened` | offene Issues | `read_api` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read_api` |
| `GET …/pipelines` | CI-Zustand des Default-Branch | `read_api` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `api` |
| `PUT …/issues/{nr}` (`state_event`) | **Issue schließen oder wieder öffnen** | `api` |
**Gitea / Forgejo** — `{Basis-URL}/api/v1`, Header `Authorization: token …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /version` | Verbindungstest | keins — der Aufruf darf fehlschlagen, Nebula wertet ihn nur ergänzend aus |
| `GET /user` | Kontoname beim Testen zurückmelden | `read:user` |
| `GET /user/repos` bzw. `GET /repos/search` | Auswahlliste hinter **Repos laden** | `read:repository` |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | `read:repository` |
| `GET …/branches` · `/tags` · `/commits` · `/releases` · `/languages` | Snapshot und Heatmap | `read:repository` |
| `GET …/contents` · `/contents/{datei}` | README (zwei Aufrufe, wie bei GitLab) | `read:repository` |
| `GET …/commits/{branch}/status` | CI-Zustand des Default-Branch | `read:repository` |
| `GET …/pulls?state=open` | offene Pull Requests | `read:repository` — Gitea zählt PRs zum Repository-Scope, nicht zu den Issues |
| `GET …/issues?state=open&type=issues` | offene Issues | `read:issue` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read:issue` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `write:issue` |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | `write:issue` |
`write:issue` schließt `read:issue` mit ein — Gitea versteht `write:` als „lesen und
schreiben". Beide anzuhaken schadet nicht, nötig ist es nicht.
#### Was Nebula nicht tut
- **Keine Kommentare.** Nebula schreibt nie einen Issue-Kommentar, weder beim Anlegen
noch beim Schließen. Was im Rumpf des angelegten Issues steht, ist die
Aufgabenbeschreibung plus eine Zeile Rückverweis — danach fasst Nebula den Text
nicht mehr an.
- **Kein Code.** Nichts wird committet, gepusht, gemergt oder getaggt. Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Keine Webhooks anlegen.** Den Haken trägst du selbst im Git-System ein — genau
deshalb verlangt Nebula dafür keine Administrationsrechte.
- **Keine Labels, keine Meilensteine, keine Zuweisungen.**
- **Keine Adminrechte**, keine Organisations- oder Benutzerverwaltung.
Kommentare und Labels, die an einem Issue hängen, stammen also entweder aus der
Weboberfläche deines Git-Systems oder von einem anderen Werkzeug mit eigenem Token —
nicht von Nebula.
#### Der GitLab-Sonderfall
GitLab kennt keinen schmalen Schreib-Scope für Issues. Laut
[Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/)
gibt es nur `api` — „complete read and write access to the API" — und darunter direkt
`read_api`. Wer bei GitLab Aufgaben als Issue anlegen will, vergibt damit ein Token, das
auch Code ändern darf.
Wenn dir das zu weit geht: bleib bei `read_api` und verzichte auf die Issue-Anbindung.
Der Abgleich läuft vollständig, und geschlossene Issues werden weiterhin erkannt und
haken ihre Aufgabe ab — nur der Rückweg von Nebula ins Issue fällt weg. Ein eigenes
Dienstkonto, das nur Zugriff auf die betroffenen Projekte hat, ist der andere Ausweg.
#### Im Zweifel klein anfangen
Ein fehlendes Recht führt zu einem `403` mit dem Hinweis „Token hat nicht die noetigen
Scopes"; die Meldung steht am Repository auf der Projektseite. Fang mit Fall A an und
leg gezielt nach, wenn du die Issue-Anbindung wirklich benutzt — statt vorsorglich
alles zu vergeben.
### Automatisch statt auf Knopfdruck ### Automatisch statt auf Knopfdruck
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
@@ -274,15 +401,25 @@ nebula/
└── src/ └── src/
├── app/ ├── app/
│ ├── page.tsx Dashboard (Board, Filter, Kennzahlen) │ ├── page.tsx Dashboard (Board, Filter, Kennzahlen)
│ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Git │ ├── projects/[id]/ Projektdetail: Beschreibung, Notizen, Aufgaben, Git
│ ├── tasks/ Aufgaben über alle Projekte, nach Fälligkeit
│ ├── rueckblick/ Wochenrückblick
│ ├── verlauf/ Fortlaufende Zeitleiste
│ ├── connections/ Verwaltung der Git-Verbindungen │ ├── connections/ Verwaltung der Git-Verbindungen
│ └── api/ REST-Endpunkte (Projekte, Notizen, Verbindungen, Sync) │ ├── account/ admin/ Eigenes Konto · Adminbereich
│ └── api/ REST-Endpunkte (siehe Abschnitt „API")
├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge) ├── components/ UI-Komponenten (Board, Karten, Panels, Dialoge)
├── lib/ ├── lib/
│ ├── auth/ Sitzungen, Passwörter, TOTP, WebAuthn, Zugriffsschutz
│ ├── crypto.ts AES-256-GCM für Access Tokens │ ├── crypto.ts AES-256-GCM für Access Tokens
│ ├── db.ts Prisma-Client │ ├── db.ts Prisma-Client
│ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts │ ├── git/ Provider-Adapter: github.ts · gitlab.ts · gitea.ts
│ ├── sync.ts Repo-Snapshot holen und speichern │ ├── sync/ Repo-Snapshot holen, speichern, Zeitgeber
│ ├── tasks/ Aufgabenlogik, Brücke zu Git-Issues
│ ├── webhooks/ Signaturprüfung eingehender Webhooks
│ ├── review.ts Wochengrenzen und Kennzahlen
│ ├── timeline.ts Cursor-Blättern für den Verlauf
│ ├── security/ SSRF-Schutz, Ratenbegrenzung, Body-Prüfung
│ └── validation.ts Zod-Schemas für alle Eingaben │ └── validation.ts Zod-Schemas für alle Eingaben
└── types/ gemeinsame Typen für Server und Client └── types/ gemeinsame Typen für Server und Client
``` ```
@@ -346,7 +483,8 @@ ausschließlich Daten des angemeldeten Kontos.
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen | | `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen | | `POST` | `/api/templates/from-project` | Aus einem Projekt eine Vorlage machen |
| `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) | | `GET` | `/api/admin/backup` | Vollständige Sicherung als JSON (Admin) |
| `GET` | `/api/search?q=` | Volltextsuche über die eigenen Notizen | | `GET` | `/api/search?q=` | Volltextsuche über eigene Notizen **und Aufgaben** |
| `GET` | `/api/activity` | Verlauf über alle Projekte, per Cursor (`?vor=&id=&limit=`) |
| `GET` `POST` | `/api/notifications` | Benachrichtigungsziele | | `GET` `POST` | `/api/notifications` | Benachrichtigungsziele |
| `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 |
@@ -358,10 +496,13 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts | | `GET` `POST` | `/api/projects/:id/repositories` | Repositories des Projekts |
| `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen | | `PATCH` `DELETE` | `/api/repositories/:id` | Repository ändern / vom Projekt lösen |
| `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen | | `POST` | `/api/repositories/:id/sync` | Ein einzelnes Repository abgleichen |
| `POST` `DELETE` | `/api/repositories/:id/webhook` | Webhook einrichten / zurücknehmen |
| `POST` | `/api/webhooks/:repositoryId` | **Eingehend** von GitHub/GitLab/Gitea, per Signatur geprüft |
| `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 | | `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 |
| `POST` | `/api/tasks/:id/issue` | Aufgabe als Issue im verknüpften Repository anlegen |
| `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts | | `GET` `POST` | `/api/projects/:id/notes` | Notizen des Projekts |
| `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen | | `PATCH` `DELETE` | `/api/notes/:id` | Notiz ändern / löschen |
| `GET` `POST` | `/api/connections` | Git-Verbindungen | | `GET` `POST` | `/api/connections` | Git-Verbindungen |
@@ -433,17 +574,21 @@ Grenzen, die du kennen solltest:
## Getestet mit ## Getestet mit
Stand 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei Am 25.07.2026 gegen echte Systeme verifiziert — beide Betriebsarten, alle drei
Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz). Git-Adapter (GitHub, GitLab und Gitea jeweils gegen die öffentliche Instanz). Seitdem
laufen die Ende-zu-Ende-Tests bei jeder Änderung mit; live nach draußen geht davon nur
ein Test gegen die GitHub-API.
Werkzeugstand vom 18.08.2026:
| | Version | | | Version |
| --- | --- | | --- | --- |
| Node.js | 26.5.0 | | Node.js | 24.19.0 |
| npm | 11.17.0 | | npm | 11.17.0 |
| Next.js | 15.5.21 | | Next.js | 15.5.21 |
| Prisma | 6.19.3 | | Prisma | 6.19.3 |
| PostgreSQL | 17 (Alpine) | | PostgreSQL | 17.11 (Alpine) |
| Docker / Compose | 29.6.2 / v5.3.1 | | Docker | 29.7.2 |
--- ---
+83 -76
View File
@@ -7,34 +7,21 @@ Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
Was bereits steht, listet [FEATURES.md](FEATURES.md). Was bereits steht, listet [FEATURES.md](FEATURES.md).
**Stand:** v1.10.1. Alle Issues aus dem Dashboard sind abgearbeitet.
--- ---
## 1 · Als Nächstes empfohlen ## 1 · Als Nächstes empfohlen
### 1.0 Rest aus v1.6.0: persönliche API-Token `M` ### 1.1 Persönliche API-Token `M`
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0 Der letzte offene Teil aus v1.6.0 („unterwegs erfassen"). Derzeit geht die
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich API nur mit Sitzungscookie. Ein Token je Konto würde `curl`-Aufrufe und
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per Handy-Shortcuts erlauben — eine Idee aus dem Terminal anlegen, ohne den
Shortcut. Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den Sitzungen.
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende Bewusst noch nicht angefasst, weil sicherheitsrelevant: Geltungsbereich,
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über Widerruf und Ablauf wollen entschieden sein, nicht nebenbei gebaut.
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
Migration wert.
---
### 1.1 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
---
### 1.2 Läuft es noch? `M` ### 1.2 Läuft es noch? `M`
@@ -43,56 +30,23 @@ Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen. greift dabei wie bei den Git-Aufrufen.
### 1.3 Persönliche API-Token `M` ### 1.3 Aufgaben ↔ Issues: der Rest `S`
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde Beide Richtungen stehen — „als Issue anlegen" seit v1.7.0, der Rückweg über
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen, Webhook (v1.7.0) und zusätzlich über den Abgleich (v1.9.0). Offen ist
ohne den Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den Kleinkram:
Sitzungen.
--- - **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
Projekt aus Frontend und Backend fehlt die Wahl.
- **Webhook beim Verknüpfen anbieten.** Er ist getrennt einzurichten, und
wer das nicht tut, merkt nur an der Verzögerung, dass etwas fehlt. Ein
Hinweis an der richtigen Stelle würde reichen.
### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M` Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und
Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt `BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den
`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0 Issue-Tracker mit Labels zu füllen, die dort niemand pflegt.
(`tasks/from-issues`); es fehlt die Gegenrichtung.
**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
Git-Operationen* — klonen, committen, pushen. Issues laufen über die
HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
**Stufe 1 — eine Richtung, ausdrücklich** `M`
Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die
vorhandenen Felder. Kein Automatismus: was nach außen geht, wird
angestoßen, nicht nebenbei mitgeschrieben.
**Stufe 2 — Rückweg über Webhooks** `M`
Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist
„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen.
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf
verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine
Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie
`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit
Zuständen zumüllen, die dort niemand pflegt.
**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht
vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für
ein persönliches Dashboard steht der Aufwand in keinem Verhältnis.
Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die
Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als
Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung
ein Commit.
--- ---
@@ -110,15 +64,60 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 3 · Kleinigkeiten ## 3 · Aufgefallen beim Bauen
Beobachtungen aus der Arbeit an v1.6.0 bis v1.10.1 — festgehalten, damit sie
nicht verloren gehen. Nichts davon ist dringend.
### 3.1 Anwendungsname bricht in der Kopfleiste ab `S`
Zwischen 320 und 375 px wird aus „Nebula" ein „Neb…". Gemessen über fünf
Breiten und zwei Seiten: betrifft alle Seiten gleichermaßen. Seit die
Aufgaben als dritter Punkt danebenstehen, ist es enger geworden.
### 3.2 Indizes für die projektübergreifenden Seiten `S`
Weder `Task` noch `Activity` haben einen Besitzer — die Trennung läuft über
`project: { ownerId }`. Die vorhandenen Indizes
(`[projectId, status, position]` bzw. `[projectId, createdAt]`) greifen für
Abfragen über alle Projekte deshalb nicht.
Betroffen sind inzwischen vier Seiten: Aufgabenübersicht, Wochenrückblick,
Zeitleiste und die Volltextsuche. Bei einigen hundert Datensätzen fällt das
nicht auf. Wird eine der Seiten träge, sind Indizes auf `Task.dueDate` und
`Activity.createdAt` der erste Griff — und dann eine Migration wert.
### 3.3 Projektfilter auf der Zeitleiste `S`
Über alle Projekte wird die Liste schnell lang. Der Wochenrückblick hat
Wochengrenzen als natürliche Bremse, die Zeitleiste nicht. Bewusst
weggelassen, weil das Issue nur „derselbe Verlauf, projektübergreifend"
verlangte.
### 3.4 Abgleich gegen geleakte Passwörter `M`
ASVS 2.1.7 verlangt es; umgesetzt ist bisher nur eine lokale Sperrliste plus
Mustererkennung (Wiederholungen, Zeichenfolgen, Benutzername im Passwort).
Have-I-Been-Pwned scheidet aus — Nebula ruft grundsätzlich nichts von außen,
und ein Passwort-Präfix nach draußen zu geben widerspräche dem erst recht.
Eine lokale Top-10000-Liste als Datei wäre der Kompromiss.
### 3.5 v0.2.0 und v0.3.0 ohne Release `S`
Beide stehen im Änderungsverlauf, existierten aber nie in der
`package.json` — dort springt die Historie von 0.1.0 auf 0.4.0. Nachgetragen
wurden sie in dem Commit, der den Verlauf einführte, und der liegt *nach*
0.4.0. Es gibt also keinen ehrlichen Commit, an dem ein Tag hängen könnte.
Die übrigen 18 Fassungen haben seit dem 18.08.2026 ein Release in Gitea.
---
## 4 · Kleinigkeiten
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Wochenrückblick | `S` | „Was ist diese Woche passiert" über alle Projekte, aus dem Aktivitätsverlauf |
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| 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 |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab | | Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.3 ab |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon | | Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| 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` |
@@ -126,7 +125,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 4 · Bewusst nicht ## 5 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum: Damit klar ist, was **nicht** kommt und warum:
@@ -139,7 +138,10 @@ Damit klar ist, was **nicht** kommt und warum:
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge - **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
bei jeder Sicherung. Ein Link ins Git tut es auch. bei jeder Sicherung. Ein Link ins Git tut es auch.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine - **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung. Übersicht, keine Entwicklungsumgebung. Issues über die HTTP-API der
Provider sind davon nicht berührt — die gibt es seit v1.7.0.
- **Beidseitige Synchronisation von Aufgaben und Issues** mit
Konfliktauflösung — siehe 1.3.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle. - **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist - **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
der Punkt. der Punkt.
@@ -172,6 +174,11 @@ Damit klar ist, was **nicht** kommt und warum:
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ | | **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ | | **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ | | **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
| **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ |
| **v1.8.0** Anmeldung und Sitzungen nach OWASP: scrypt-Aufwand, Aufzählbarkeit, Leerlauffrist, Cookie-Härtung | ✓ |
| **v1.9.0** Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab | ✓ |
| **v1.10.0** Wochenrückblick, globale Zeitleiste, Suche über Aufgaben | ✓ |
| **v1.10.1** Issue-Kennzeichen auf der Aufgabenkarte, Token-Rechte Aufruf für Aufruf belegt | ✓ |
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
+337
View File
@@ -1,4 +1,13 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
/**
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/** /**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware, * Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
@@ -279,6 +288,228 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr"); expect(zumSchluss.repositories[0].repoName).toBe("swr");
}); });
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
await login(request, ADMIN);
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title: "Haengt an einem geschlossenen Issue" },
});
const taskId = (await angelegt.json()).task.id;
/**
* Die Verknuepfung direkt in der Datenbank setzen.
*
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
*
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
*/
const prisma = new PrismaClient({
datasources: { db: { url: DATABASE_URL } },
});
try {
await prisma.task.update({
where: { id: taskId },
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
});
} finally {
await prisma.$disconnect();
}
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const alsAngelegt = vorher.project.tasks.find(
(t: { id: string }) => t.id === taskId,
);
expect(alsAngelegt.sourceIssue).toBe(1);
expect(alsAngelegt.status).toBe("TODO");
// Abgleich anstossen – er fragt den Zustand des Issues nach.
const sync = await request.post(`/api/repositories/${repoId}/sync`);
const syncBody = await sync.json();
/**
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
*/
if (!sync.ok()) {
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
return;
}
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
expect(abgehakt.status).toBe("DONE");
expect(abgehakt.doneAt).not.toBeNull();
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
// ohne dass erneut nachgefragt wird.
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Hakenprojekt", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
// Ohne eingerichteten Webhook ist der Endpunkt stumm – und zwar mit 404,
// damit die ID nicht bestaetigt wird.
const ohne = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json" },
data: { action: "closed", issue: { number: 1 } },
});
expect(ohne.status()).toBe(404);
// Einrichten liefert das Geheimnis genau einmal zurueck.
const eingerichtet = await request.post(`/api/repositories/${repoId}/webhook`);
expect(eingerichtet.status()).toBe(200);
const { secret, url } = await eingerichtet.json();
expect(typeof secret).toBe("string");
expect(secret.length).toBeGreaterThanOrEqual(64);
expect(url).toContain(`/api/webhooks/${repoId}`);
// Und danach nirgends mehr: weder im Projekt noch am Repository.
const geladen = await (await request.get(`/api/projects/${projekt.id}`)).json();
expect(JSON.stringify(geladen)).not.toContain(secret);
expect(geladen.project.repositories[0].webhookSecret).toBeUndefined();
// Eine Aufgabe, die an einem Issue haengt – von Hand gesetzt gibt es
// nicht, also ueber die Uebernahme aus dem Snapshot. Stattdessen prueft
// dieser Test die Grenze selbst: ohne gueltige Signatur passiert nichts.
const rumpf = JSON.stringify({ action: "closed", issue: { number: 4242 } });
const falsch = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": "sha256=00",
},
data: rumpf,
});
expect(falsch.status()).toBe(401);
const gar_keine = await request.post(`/api/webhooks/${repoId}`, {
headers: { "Content-Type": "application/json", "X-GitHub-Event": "issues" },
data: rumpf,
});
expect(gar_keine.status()).toBe(401);
// Mit richtiger Signatur wird der Aufruf angenommen. Zu Issue 4242 gibt
// es keine Aufgabe – erwartet ist also "angenommen, nichts zugeordnet".
const { createHmac } = await import("node:crypto");
const signatur = createHmac("sha256", secret).update(rumpf, "utf8").digest("hex");
const richtig = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(richtig.status()).toBe(200);
const antwort = await richtig.json();
expect(antwort).toMatchObject({ ok: true, handled: "issue", matched: false });
// Dieselbe Signatur zu einem anderen Rumpf gilt nicht.
const veraendert = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: JSON.stringify({ action: "closed", issue: { number: 9999 } }),
});
expect(veraendert.status()).toBe(401);
// Fremde Konten koennen den Haken weder einrichten noch zuruecknehmen.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await fremd.post(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
expect((await fremd.delete(`/api/repositories/${repoId}/webhook`)).status()).toBe(404);
await fremd.dispose();
// Zuruecknehmen schliesst den Endpunkt wieder.
expect((await request.delete(`/api/repositories/${repoId}/webhook`)).ok()).toBeTruthy();
const danach = await request.post(`/api/webhooks/${repoId}`, {
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-Hub-Signature-256": `sha256=${signatur}`,
},
data: rumpf,
});
expect(danach.status()).toBe(404);
});
test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => { test("Brett: Reihenfolge und Mehrfachauswahl", async ({ request, playwright }) => {
await login(request, ADMIN); await login(request, ADMIN);
@@ -532,6 +763,112 @@ test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, play
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy(); expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
}); });
test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, playwright }) => {
await login(request, ADMIN);
// Die bisherigen Tests haben reichlich Verlauf erzeugt. Erst alles auf
// einmal holen, dann in kleinen Seiten – beides muss dieselbe Menge sein.
const alles = await (await request.get("/api/activity?limit=100")).json();
expect(alles.activities.length).toBeGreaterThan(5);
const ids: string[] = [];
let cursor: { vor: string; id: string } | null = null;
for (let seite = 0; seite < 60; seite += 1) {
const query = cursor
? `?limit=3&vor=${encodeURIComponent(cursor.vor)}&id=${encodeURIComponent(cursor.id)}`
: "?limit=3";
const antwort = await request.get(`/api/activity${query}`);
expect(antwort.ok()).toBeTruthy();
const seiteBody = await antwort.json();
ids.push(...seiteBody.activities.map((eintrag: { id: string }) => eintrag.id));
if (!seiteBody.weitere) break;
cursor = seiteBody.naechster;
expect(cursor).not.toBeNull();
}
// Der eigentliche Punkt: keine Dubletten und keine Luecken, auch wenn
// mehrere Eintraege denselben Zeitstempel tragen.
expect(new Set(ids).size).toBe(ids.length);
expect(ids.length).toBe(alles.activities.length);
// Absteigend sortiert.
const zeiten: number[] = alles.activities.map((eintrag: { createdAt: string }) =>
Date.parse(eintrag.createdAt),
);
for (let i = 1; i < zeiten.length; i += 1) {
expect(zeiten[i]).toBeLessThanOrEqual(zeiten[i - 1]);
}
// Unbrauchbare Cursor fangen von vorn an, statt zu scheitern.
const kaputt = await request.get("/api/activity?vor=keindatum&id=x");
expect(kaputt.ok()).toBeTruthy();
expect((await kaputt.json()).activities.length).toBeGreaterThan(0);
// Und ein fremdes Konto sieht ausschliesslich seinen eigenen Verlauf.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const fremderVerlauf = await (await fremd.get("/api/activity")).json();
const eigeneIds = new Set(ids);
for (const eintrag of fremderVerlauf.activities) {
expect(eigeneIds.has(eintrag.id)).toBe(false);
}
await fremd.dispose();
});
test("Volltextsuche findet auch Aufgaben", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Suchprojekt" } })).json()
).project;
for (const task of [
{
title: "Dockerfile aufraeumen",
description: "Die Schichten im Container neu ordnen, damit der Bau schneller wird.",
},
{ title: "Rechnungen exportieren", description: "Monatlicher Export als CSV." },
{ title: "Erledigtes mit Container", description: "Auch das soll gefunden werden.", status: "DONE" },
{ title: "Ohne Bezug", description: "Voellig anderer Inhalt." },
]) {
expect((await request.post(`/api/projects/${projekt.id}/tasks`, { data: task })).status()).toBe(
201,
);
}
// Titel und Beschreibung werden durchsucht.
const container = await (await request.get("/api/search?q=Container")).json();
const titel = container.tasks.map((treffer: { title: string }) => treffer.title);
expect(titel).toContain("Dockerfile aufraeumen");
// Erledigtes bleibt drin – wer sucht, sucht oft gerade das Abgehakte.
expect(titel).toContain("Erledigtes mit Container");
expect(titel).not.toContain("Ohne Bezug");
// Deutsche Stammformen: "Rechnung" findet "Rechnungen".
const rechnung = await (await request.get("/api/search?q=Rechnung")).json();
expect(rechnung.tasks.map((t: { title: string }) => t.title)).toContain(
"Rechnungen exportieren",
);
// Das Projekt taucht in projectIds auf, damit das Brett es einblendet.
expect(container.projectIds).toContain(projekt.id);
// Zu kurze Eingaben liefern leere Listen statt Unsinn.
const kurz = await (await request.get("/api/search?q=a")).json();
expect(kurz.tasks).toEqual([]);
expect(kurz.hits).toEqual([]);
// Und ein fremdes Konto findet nichts davon.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/search?q=Container")).json()).tasks).toHaveLength(0);
await fremd.dispose();
});
test("Der Bildoptimierer ist zu", async ({ playwright }) => { test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer // /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image // das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
+110 -1
View File
@@ -163,6 +163,46 @@ test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }
await expect(page.getByText("Aus der Bahn notiert")).toBeVisible(); await expect(page.getByText("Aus der Bahn notiert")).toBeVisible();
}); });
test("Dialoge weichen der Bildschirmtastatur aus", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const TASTATUR = 336;
/**
* Eine echte Tastatur laesst sich nicht einblenden. Stattdessen meldet
* visualViewport eine kleinere Hoehe – genau die Schnittstelle, an der
* der Dialog haengt. Ueberschrieben wird am echten Objekt: der Dialog
* merkt sich beim Oeffnen eine Referenz darauf.
*/
await page.evaluate((tastatur) => {
const fenster = window.visualViewport;
if (!fenster) throw new Error("visualViewport fehlt");
const hoehe = window.innerHeight - tastatur;
Object.defineProperty(fenster, "height", { get: () => hoehe, configurable: true });
Object.defineProperty(fenster, "offsetTop", { get: () => 0, configurable: true });
fenster.dispatchEvent(new Event("resize"));
}, TASTATUR);
await page.waitForTimeout(300);
// Eingabefeld und Absendeknopf muessen oberhalb der Tastatur liegen.
// Vorher stand der Dialog mittig im vollen Fenster – und damit zur
// Haelfte darunter.
const frei = 667 - TASTATUR;
for (const ziel of [
page.locator("#quick-title"),
page.getByRole("dialog").getByRole("button", { name: "Anlegen" }),
]) {
const kasten = await ziel.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.y + kasten!.height).toBeLessThanOrEqual(frei);
}
await page.keyboard.press("Escape");
});
test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => { test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click(); await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible(); await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
@@ -183,6 +223,75 @@ test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page
).toBeVisible(); ).toBeVisible();
}); });
test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => {
// Erreichbar vom Dashboard aus, ohne vierten Punkt in der Kopfleiste.
await page.getByRole("link", { name: /Wochenrückblick/ }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
await expect(page.getByRole("heading", { name: "Diese Woche", level: 1 })).toBeVisible();
// Die Vorgaenge der bisherigen Tests liegen in dieser Woche, es muss also
// etwas dastehen – und das Projekt gehoert an jede Zeile.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
// Blättern: zurück und wieder her.
await page.getByRole("link", { name: "Eine Woche zurück" }).click();
await expect(page).toHaveURL(/woche=-1/);
await expect(page.getByRole("heading", { name: "Vorige Woche", level: 1 })).toBeVisible();
await page.getByRole("link", { name: "Diese Woche", exact: true }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/^Projekt, /).fill("Rückblick");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/rueckblick/);
});
test("Globale Zeitleiste zeigt den Verlauf ueber alle Projekte", async ({ page }) => {
// Vom Wochenrueckblick aus verlinkt.
await page.goto("/rueckblick");
await page.getByRole("link", { name: /Ganzer Verlauf/ }).click();
await expect(page).toHaveURL(/\/verlauf$/);
await expect(page.getByRole("heading", { name: "Verlauf", level: 1 })).toBeVisible();
// Eintraege mit Projektbezug – ohne den ist eine uebergreifende Liste
// nicht zu lesen.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
await expect(page.getByText(/Vorgänge geladen|Vorgang geladen/)).toBeVisible();
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/^Projekt, /).fill("Verlauf");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/verlauf/);
});
test("Aufgabendashboard laesst sich durchsuchen", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
const suchfeld = page.getByLabel("Aufgaben durchsuchen");
await expect(suchfeld).toBeVisible();
// Aus den frueheren Tests gibt es "Erste Aufgabe" und "Zweite Aufgabe".
await suchfeld.fill("Erste");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
await expect(page.getByRole("listitem").filter({ hasText: "Zweite Aufgabe" })).toBeHidden();
// Ohne Treffer erklaert der Leerzustand, worin gesucht wurde.
await suchfeld.fill("gibtesnichtxyz");
await expect(page.getByText("Keine Treffer")).toBeVisible();
await expect(page.getByText(/Titel, Beschreibung, Labels und Projektname/)).toBeVisible();
// Zuruecksetzen bringt alles wieder.
await page.getByRole("button", { name: "Suche zuruecksetzen" }).click();
await expect(suchfeld).toHaveValue("");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
@@ -190,7 +299,7 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
// Ohne Eingabe stehen Projekte und Bereiche bereit. // Ohne Eingabe stehen Projekte und Bereiche bereit.
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible(); await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig"); await page.getByPlaceholder(/^Projekt, /).fill("Zaunkoenig");
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible(); await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
// Enter springt zum ersten Treffer. // Enter springt zum ersten Treffer.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.2.0", "version": "1.10.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "1.2.0", "version": "1.10.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.6.0", "version": "1.10.1",
"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": {
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3),
ADD COLUMN "webhookSecret" TEXT;
@@ -0,0 +1,20 @@
-- Volltextsuche ueber Aufgaben.
--
-- Wie Note_fulltext_idx ein Ausdrucksindex: Prisma kann so etwas im Schema
-- nicht abbilden und kennt ihn deshalb nicht. Nachgeprueft mit
-- "prisma migrate diff" gegen das Schema - es kommt "empty migration"
-- heraus, der Index wird also weder angelegt noch geloescht. Trotzdem beim
-- Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss Zeichen fuer Zeichen dem in src/app/api/search/route.ts
-- entsprechen, sonst greift der Index nicht. Die zweistellige Form von
-- to_tsvector mit fester Konfiguration ist IMMUTABLE und damit indizierbar.
--
-- Anders als bei der Notiz ist auch die Beschreibung optional, deshalb steht
-- coalesce um beide Spalten.
CREATE INDEX "Task_fulltext_idx" ON "Task"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || coalesce("description", '')));
+10
View File
@@ -407,6 +407,16 @@ model Repository {
notifiedCiState String? notifiedCiState String?
notifiedRelease String? notifiedRelease String?
/// Gemeinsames Geheimnis fuer eingehende Webhooks, verschluesselt wie die
/// Zugangstoken. Je Repository, weil der Haken im Git-System dort
/// eingetragen wird und sich einzeln zuruecknehmen laesst.
/// NULL heisst: fuer dieses Repository ist kein Webhook eingerichtet, und
/// eingehende Aufrufe werden abgewiesen.
webhookSecret String?
/// Wann zuletzt ein gueltiger Webhook ankam – die einzige Rueckmeldung
/// darueber, ob der Haken im Git-System wirklich funktioniert.
webhookLastAt DateTime?
snapshot RepoSnapshot? snapshot RepoSnapshot?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
} }
} }
const weak = validatePasswordStrength(parsed.data.newPassword); const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
} }
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import {
SEITEN_ORDNUNG,
cursorAus,
seitenBedingung,
seitenGroesse,
} from "@/lib/timeline";
export const dynamic = "force-dynamic";
/**
* Der Verlauf ueber alle Projekte, neueste Eintraege zuerst.
*
* Geblaettert wird per Cursor (`vor` = Zeitpunkt, `id` = Eintrag), nicht per
* Offset – siehe lib/timeline. Zurueck kommt der Cursor fuer die naechste
* Seite gleich mit, damit der Aufrufer ihn nicht selbst zusammensetzen muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const params = new URL(request.url).searchParams;
const cursor = cursorAus(params.get("vor"), params.get("id"));
const take = seitenGroesse(params.get("limit"));
/**
* Einen mehr holen als angefragt: daran laesst sich ablesen, ob es
* weitergeht, ohne dafuer zaehlen zu muessen.
*/
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(auth.user.id, cursor),
orderBy: SEITEN_ORDNUNG,
take: take + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > take;
const activities = weitere ? gefunden.slice(0, take) : gefunden;
const letzter = activities[activities.length - 1];
return NextResponse.json({
activities: activities.map((eintrag) => ({
...eintrag,
createdAt: eintrag.createdAt.toISOString(),
})),
weitere,
naechster: weitere && letzter ? { vor: letzter.createdAt.toISOString(), id: letzter.id } : null,
});
}
+1 -1
View File
@@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) {
} }
if (password) { if (password) {
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, target.username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, role } = parsed.data; const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+36 -14
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa"; import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; import { fakeVerifyDelay, hashPassword, needsRehash, 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";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -42,6 +42,30 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401); return jsonError(GENERIC, 401);
} }
/**
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
*
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
* die der einheitliche Text GENERIC sonst verhindert.
*
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
*/
const valid = user.passwordHash
? await verifyPassword(password, user.passwordHash)
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
await fakeVerifyDelay().then(() => false);
if (!valid) {
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
if (user.passwordHash) await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil); const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) { if (locked > 0) {
return jsonError( return jsonError(
@@ -51,22 +75,20 @@ export async function POST(request: Request) {
} }
if (!user.isActive) { if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403); return jsonError("Dieses Konto ist deaktiviert.", 403);
} }
if (!user.passwordHash) { /**
await fakeVerifyDelay(); * Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
return jsonError( * anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.", * bekommt hier still einen neuen – es ist der einzige Moment, in dem das
401, * Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
); * die halbe Sekunde wartet.
} */
if (needsRehash(user.passwordHash)) {
const valid = await verifyPassword(password, user.passwordHash); void hashPassword(password)
if (!valid) { .then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
await noteFailedLogin(user.id); .catch(() => undefined);
return jsonError(GENERIC, 401);
} }
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier // Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
+1 -1
View File
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password } = parsed.data; const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
} }
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, mode } = parsed.data; const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password); const weak = validatePasswordStrength(password, username);
if (weak) { if (weak) {
return NextResponse.json( return NextResponse.json(
{ error: weak, fields: { password: weak } }, { error: weak, fields: { password: weak } },
@@ -0,0 +1,63 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { APP_URL } from "@/lib/config";
import { encryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { ownedRepository } from "@/lib/repositories";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Richtet den Webhook eines Repositories ein oder nimmt ihn zurueck.
*
* Das Geheimnis wird hier erzeugt und **genau einmal** im Klartext
* zurueckgegeben – danach liegt es nur noch verschluesselt in der Datenbank,
* wie die Zugangstoken. Wer es verliert, erzeugt ein neues; das alte gilt
* dann nicht mehr.
*
* Eingetragen wird der Haken im Git-System von Hand. Ihn ueber die API
* anzulegen waere machbar, verlangte aber Administrationsrechte am
* Repository – deutlich mehr, als Nebula sonst braucht.
*/
export async function POST(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const repository = await ownedRepository(id, auth.user.id);
if (!repository) return jsonError("Repository nicht gefunden", 404);
// 32 Byte, hexadezimal. Lang genug, dass Raten aussichtslos ist, und
// ohne Sonderzeichen, die in Formularfeldern Aerger machen.
const secret = randomBytes(32).toString("hex");
await prisma.repository.update({
where: { id: repository.id },
data: { webhookSecret: encryptSecret(secret), webhookLastAt: null },
});
return NextResponse.json({
secret,
url: `${APP_URL}/api/webhooks/${repository.id}`,
});
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const repository = await ownedRepository(id, auth.user.id);
if (!repository) return jsonError("Repository nicht gefunden", 404);
await prisma.repository.update({
where: { id: repository.id },
data: { webhookSecret: null, webhookLastAt: null },
});
return NextResponse.json({ ok: true });
}
+58 -7
View File
@@ -12,16 +12,30 @@ interface Hit {
snippet: string; snippet: string;
} }
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
/** Ausschnitt aus der Beschreibung; leer, wenn nur der Titel passte. */
snippet: string;
}
/** /**
* Volltextsuche ueber Notizen. * Volltextsuche ueber Notizen und Aufgaben.
* *
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt * Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die * Name, Kurzbeschreibung und Tags ab. Notizen und Aufgaben liegen dort nicht
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt: * vor – die mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank. * Endpunkt: Postgres sucht mit seiner deutschen Textsuche direkt in der
* Datenbank.
* *
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung * ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss. * kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*
* Das Feld heisst weiterhin "hits" und meint die Notizen – umbenennen haette
* nur Aufrufer gebrochen, ohne etwas zu verbessern. Aufgaben kommen als
* eigenes Feld dazu.
*/ */
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await requireUserApi(); const auth = await requireUserApi();
@@ -29,7 +43,7 @@ export async function GET(request: Request) {
const query = (new URL(request.url).searchParams.get("q") ?? "").trim(); const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) { if (query.length < 2) {
return NextResponse.json({ query, hits: [], projectIds: [] }); return NextResponse.json({ query, hits: [], tasks: [], projectIds: [] });
} }
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder // websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
@@ -57,11 +71,48 @@ export async function GET(request: Request) {
LIMIT 50 LIMIT 50
`; `;
/**
* Dieselbe Rechnung fuer Aufgaben.
*
* Der Ausdruck in to_tsvector muss Zeichen fuer Zeichen dem in der
* Migration entsprechen (Task_fulltext_idx), sonst greift der Index nicht
* und Postgres rechnet ihn je Zeile neu.
*
* Erledigtes bleibt drin: wer sucht, sucht oft gerade das, was er letzte
* Woche abgehakt hat.
*/
const tasks = await prisma.$queryRaw<TaskHit[]>`
SELECT
t."projectId" AS "projectId",
t."id" AS "taskId",
t."title" AS "title",
t."status"::text AS "status",
ts_headline(
'german',
coalesce(t."description", ''),
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Task" t
JOIN "Project" p ON p."id" = t."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", ''))
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", '')),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({ return NextResponse.json({
query, query,
hits, hits,
tasks,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im // Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden. // Board einzublenden – jetzt aus beiden Quellen.
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))), projectIds: Array.from(
new Set([...hits.map((hit) => hit.projectId), ...tasks.map((hit) => hit.projectId)]),
),
}); });
} }
+141
View File
@@ -0,0 +1,141 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { GitApiError, clientForConnection } from "@/lib/git";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
* Legt zu einer Aufgabe ein Issue im verknuepften Repository an.
*
* Der einzige Weg, auf dem Nebula von sich aus etwas in ein fremdes System
* schreibt – deshalb ausdruecklich angestossen und nie nebenbei. Umgekehrt
* gibt es die Uebernahme von Issues als Aufgaben schon laenger
* (tasks/from-issues); die Felder sourceIssue, sourceUrl und
* sourceRepositoryId sind dieselben, und die Eindeutigkeit je Repository
* verhindert, dass beide Wege dasselbe doppelt anlegen.
*/
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Besitz haengt am Projekt – die Aufgabe selbst kennt keinen Besitzer.
const task = await prisma.task.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: {
id: true,
title: true,
description: true,
projectId: true,
sourceIssue: true,
sourceUrl: true,
project: { select: { name: true } },
},
});
if (!task) return jsonError("Aufgabe nicht gefunden", 404);
// Schon verknuepft: nicht noch einmal anlegen. Ein zweites Issue zur
// selben Aufgabe waere genau die Dublette, die from-issues vermeidet.
if (task.sourceIssue !== null) {
return NextResponse.json(
{
error: "Diese Aufgabe haengt bereits an einem Issue.",
issue: { number: task.sourceIssue, url: task.sourceUrl },
},
{ status: 409 },
);
}
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: task.projectId,
...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}),
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { connection: true },
});
if (!repository) {
return jsonError("Das Projekt hat kein verknuepftes Repository.", 422);
}
if (!repository.connection) {
return jsonError(
"Das Repository haengt an keiner Git-Verbindung – ohne Token laesst sich nichts anlegen.",
422,
);
}
if (!repository.repoOwner || !repository.repoName) {
return jsonError("Am Repository fehlen Owner oder Name.", 422);
}
const client = clientForConnection(repository.connection);
// Rueckverweis in den Rumpf: wer das Issue im Git-System liest, soll
// wissen, wo es herkommt und wo der Rest der Notizen liegt.
const rumpf = [
task.description?.trim() || null,
`_Angelegt aus [${task.project.name}](${APP_URL}/projects/${task.projectId}) in Nebula._`,
]
.filter(Boolean)
.join("\n\n");
try {
const issue = await client.createIssue(
{ owner: repository.repoOwner, name: repository.repoName },
{ title: task.title, body: rumpf },
);
const aktualisiert = await prisma.task.update({
where: { id: task.id },
data: {
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
});
await recordActivity({
projectId: task.projectId,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Issue #${issue.number} angelegt zu: ${shorten(task.title, 50)}`,
meta: { taskId: task.id, issue: issue.number, url: issue.url },
});
return NextResponse.json({ task: aktualisiert, issue }, { status: 201 });
} catch (error) {
// Der Aufruf ging nach draussen; die Meldung des Git-Systems ist
// hilfreicher als ein pauschales "hat nicht geklappt".
if (error instanceof GitApiError) {
return jsonError(`Issue konnte nicht angelegt werden: ${error.message}`, 502);
}
return jsonError("Issue konnte nicht angelegt werden.", 502);
}
}
+18
View File
@@ -10,6 +10,7 @@ import {
refreshDerivedProgress, refreshDerivedProgress,
spawnRecurrence, spawnRecurrence,
} from "@/lib/tasks"; } from "@/lib/tasks";
import { issueShouldBeOpen, mirrorStatusToIssue } from "@/lib/tasks/issues";
import { formatZodError, taskUpdateSchema } from "@/lib/validation"; import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -74,6 +75,23 @@ export async function PATCH(request: Request, { params }: Params) {
await refreshDerivedProgress(existing.projectId); await refreshDerivedProgress(existing.projectId);
/**
* Haengt die Aufgabe an einem Issue, wandert der Zustand mit: erledigt
* schliesst es, ein Zurueckziehen oeffnet es wieder. Nur die Grenze um
* ERLEDIGT geht nach draussen – mehr kennt ein Issue nicht.
*
* Fehlertolerant und ohne den Aufrufer warten zu lassen, was schiefgehen
* kann: ein nicht erreichbares Git-System darf das Abhaken nicht
* verhindern. Beim naechsten Wechsel wird es erneut versucht.
*/
if (update.status !== undefined) {
const vorher = issueShouldBeOpen(existing.status);
const nachher = issueShouldBeOpen(update.status as TaskStatus);
if (vorher !== nachher) {
await mirrorStatusToIssue(id, update.status as TaskStatus);
}
}
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder // Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu // ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat. // erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
@@ -0,0 +1,142 @@
import { NextResponse } from "next/server";
import { tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { rateLimit } from "@/lib/security/rateLimit";
import { syncRepository } from "@/lib/sync";
import { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues";
import { refreshDerivedProgress } from "@/lib/tasks";
import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ repositoryId: string }> };
/** Groesse des Rumpfes deckeln – ein Push-Ereignis ist nie ein Megabyte. */
const MAX_BODY = 512 * 1024;
/**
* Eingehende Webhooks von GitHub, GitLab und Gitea.
*
* Der einzige Endpunkt ohne Anmeldung, der etwas schreibt. Ausgewiesen wird
* sich mit einer Signatur ueber den Rumpf, geprueft gegen das Geheimnis, das
* beim Einrichten am Repository hinterlegt wurde.
*
* Absichtlich sparsam in dem, was er tut:
*
* push – stoesst denselben Abgleich an wie der Zeitgeber
* issues – geschlossen oder wieder geoeffnet wandert an die Aufgabe
*
* Alles andere wird angenommen und liegen gelassen. Ein Webhook, der mit
* einem Fehler antwortet, wird von den Systemen wiederholt und irgendwann
* abgeschaltet – deshalb ist "verstanden, nichts zu tun" die richtige
* Antwort auf ein unbekanntes Ereignis.
*
* Was hier NICHT passiert: der Rumpf wird nie als Quelle fuer Inhalte
* benutzt. Titel, Beschreibung und Zustand einer Aufgabe kommen weiter aus
* Nebula beziehungsweise beim naechsten Abgleich aus der API. Wer das
* Geheimnis kennt, kann also hoechstens eine Aufgabe auf- oder zumachen und
* einen Abgleich ausloesen – nicht beliebige Daten einschleusen.
*/
export async function POST(request: Request, { params }: Params) {
const { repositoryId } = await params;
// Die ID steht in der URL und ist damit halboeffentlich. Ein Deckel
// dagegen, sie als Orakel zu benutzen oder den Abgleich zu verheizen.
const limit = rateLimit(`webhook:${repositoryId}`, 60, 60_000);
if (!limit.ok) {
return NextResponse.json(
{ error: "Zu viele Aufrufe." },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
);
}
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: { connection: true },
});
// Unbekanntes Repository und fehlendes Geheimnis sind dieselbe Antwort:
// ob es die ID gibt, geht den Anrufer nichts an.
const secret = repository?.webhookSecret
? tryDecryptSecret(repository.webhookSecret)
: null;
if (!repository || !secret) {
return NextResponse.json({ error: "Unbekannt." }, { status: 404 });
}
const roh = await request.text();
if (roh.length > MAX_BODY) {
return NextResponse.json({ error: "Rumpf zu gross." }, { status: 413 });
}
/**
* Der **rohe** Rumpf geht in die Pruefung, nicht das geparste Objekt:
* durch JSON.parse und JSON.stringify gedreht kaeme eine andere Zeichen-
* folge heraus und die Signatur passte nie.
*/
const provider = repository.connection?.provider ?? "GITEA";
const geprueft = verifySignature(provider, secret, roh, request.headers);
if (!geprueft.ok) {
return NextResponse.json({ error: "Signatur ungueltig." }, { status: 401 });
}
let payload: unknown = null;
try {
payload = JSON.parse(roh);
} catch {
return NextResponse.json({ error: "Kein gueltiges JSON." }, { status: 400 });
}
const eventName =
request.headers.get("x-github-event") ??
request.headers.get("x-gitea-event") ??
request.headers.get("x-gitlab-event");
const ereignis = parseEvent(provider, eventName, payload);
// Ab hier gilt der Aufruf als echt – das ist die Rueckmeldung darueber,
// ob der Haken im Git-System ueberhaupt funktioniert.
await prisma.repository.update({
where: { id: repository.id },
data: { webhookLastAt: new Date() },
});
if (ereignis.kind === "push") {
// Derselbe Weg wie beim Zeitgeber – nur eben sofort statt beim
// naechsten Durchlauf.
await syncRepository(repository.id).catch(() => undefined);
return NextResponse.json({ ok: true, handled: "push" });
}
if (ereignis.kind === "issue") {
const task = await prisma.task.findFirst({
where: { sourceRepositoryId: repository.id, sourceIssue: ereignis.number },
select: { id: true, status: true, projectId: true },
});
if (!task) {
return NextResponse.json({ ok: true, handled: "issue", matched: false });
}
const ziel = statusForIssueState(ereignis.open, task.status);
if (ziel === "DONE" && task.status !== "DONE") {
// Ueber die gemeinsame Funktion, damit ein geschlossenes Issue
// dasselbe bewirkt wie ein Haken in der Oberflaeche: naechste Fassung
// einer wiederkehrenden Aufgabe und ein Eintrag im Verlauf.
await completeTaskFromIssue(task.id, "webhook");
} else if (ziel !== task.status) {
// Wieder geoeffnet. Das darf nur der Webhook, weil hier ein echtes
// "reopened" vorliegt – der Abgleich sieht nur Zustaende und wuerde
// sonst eine bewusst erledigte Aufgabe aufmachen.
await prisma.task.update({
where: { id: task.id },
data: { status: ziel, doneAt: null },
});
await refreshDerivedProgress(task.projectId);
}
return NextResponse.json({ ok: true, handled: "issue", matched: true, status: ziel });
}
return NextResponse.json({ ok: true, handled: "ignored", event: ereignis.name });
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react"; import Link from "next/link";
import { CalendarRange, CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
import { ProjectBoard } from "@/components/ProjectBoard"; import { ProjectBoard } from "@/components/ProjectBoard";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
@@ -50,6 +51,15 @@ export default async function DashboardPage() {
Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den
verknuepften Repositories bei GitHub, GitLab und Gitea passiert. verknuepften Repositories bei GitHub, GitLab und Gitea passiert.
</p> </p>
{/* Kein weiterer Punkt in der Kopfleiste – die ist auf dem Telefon
schon eng. Der Rueckblick haengt dort, wo man ohnehin hinsieht. */}
<Link
href="/rueckblick"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-violet-300 underline-offset-2 hover:underline"
>
<CalendarRange className="h-4 w-4" />
Wochenrückblick ansehen
</Link>
</header> </header>
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf {/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
+15 -3
View File
@@ -11,7 +11,7 @@ import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects"; import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types"; import type { ConnectionSafe, ProjectDetail } from "@/types";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -55,6 +55,9 @@ export default async function ProjectPage({ params }: PageProps) {
const detail = project as unknown as ProjectDetail; const detail = project as unknown as ProjectDetail;
// Nur die Angabe "hier haengt ein Webhook", nicht das Geheimnis selbst.
const webhookIds = await webhookRepositoryIds(detail.id);
const fuehrend = detail.repositories[0]?.snapshot; const fuehrend = detail.repositories[0]?.snapshot;
const readme = fuehrend?.readme const readme = fuehrend?.readme
? { ? {
@@ -82,8 +85,16 @@ export default async function ProjectPage({ params }: PageProps) {
progressFromTasks={detail.progressFromTasks} progressFromTasks={detail.progressFromTasks}
/> />
{/*
min-w-0 an beiden Spalten ist hier nicht Kosmetik, sondern der Kern:
ein Rasterkind hat "min-width: auto" und weigert sich damit, unter
die Inhaltsbreite seines breitesten Kindes zu schrumpfen. Die
Heatmap ist ueber 700px breit – auf dem Telefon zog sie damit die
ganze Spalte und mit ihr die Seite auseinander. Erst mit min-w-0
greift das overflow-x-auto darin ueberhaupt.
*/}
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5"> <div className="min-w-0 space-y-6 lg:col-span-5">
<section className="glass p-5"> <section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300"> <h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<FileText className="h-4 w-4 text-cyan-300" /> <FileText className="h-4 w-4 text-cyan-300" />
@@ -112,10 +123,11 @@ 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="space-y-6 lg:col-span-7"> <div className="min-w-0 space-y-6 lg:col-span-7">
<GitPanel <GitPanel
project={detail} project={detail}
connections={connections as unknown as ConnectionSafe[]} connections={connections as unknown as ConnectionSafe[]}
webhookRepoIds={webhookIds}
/> />
<ActivityFeed projectId={detail.id} initial={detail.activities} /> <ActivityFeed projectId={detail.id} initial={detail.activities} />
</div> </div>
+139
View File
@@ -0,0 +1,139 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, CalendarRange, History } from "lucide-react";
import { WeeklyReview } from "@/components/WeeklyReview";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { tagesschluessel, wocheUm, wochenName, wochenTitel, zaehleWoche } from "@/lib/review";
import type { ReviewDay, ReviewEntry } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Wochenrückblick" };
/** Wie weit sich zurueckblaettern laesst – der Verlauf haelt ohnehin nur 200 je Projekt. */
const MAX_RUECKBLICK = 25;
export default async function ReviewPage({
searchParams,
}: {
searchParams: Promise<{ woche?: string }>;
}) {
const user = await requireUserPage();
const params = await searchParams;
// Nur nach hinten blaettern: die Zukunft ist im Rueckblick leer.
const gewuenscht = Number(params.woche ?? 0);
const versatz = Number.isFinite(gewuenscht)
? Math.min(0, Math.max(-MAX_RUECKBLICK, Math.trunc(gewuenscht)))
: 0;
const woche = wocheUm(new Date(), versatz);
/**
* Der Verlauf kennt keinen Besitzer – die Trennung laeuft ueber das
* Projekt. Die Bedingung steht deshalb als erste in der where-Klausel.
*/
const activities = await prisma.activity.findMany({
where: {
project: { ownerId: user.id },
createdAt: { gte: woche.von, lt: woche.bis },
},
include: {
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
// meta kommt mit, weil die Kennzahlen daran ablesen, was erledigt
// wurde – verlaesslicher als der Anzeigetext.
orderBy: { createdAt: "desc" },
// Deckel gegen eine Woche, in der ein Abgleich Amok gelaufen ist.
take: 500,
});
/**
* Nach Kalendertagen buendeln – auf dem Server, damit Server-Render und
* Hydration nicht auseinanderlaufen. "Welcher Tag" haengt an der Zeitzone.
*/
const nachTag = new Map<string, ReviewEntry[]>();
for (const eintrag of activities) {
const schluessel = tagesschluessel(eintrag.createdAt);
const liste = nachTag.get(schluessel) ?? [];
liste.push({
id: eintrag.id,
kind: eintrag.kind,
summary: eintrag.summary,
createdAt: eintrag.createdAt.toISOString(),
project: eintrag.project,
user: eintrag.user,
});
nachTag.set(schluessel, liste);
}
// Neuester Tag zuerst, und nur Tage, an denen etwas war.
const tage: ReviewDay[] = [...woche.tage]
.reverse()
.map((tag) => ({ tag, eintraege: nachTag.get(tag) ?? [] }))
.filter((eintrag) => eintrag.eintraege.length > 0);
const kennzahlen = zaehleWoche(activities);
return (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{wochenName(woche)}
</h1>
<p className="mt-1 flex items-center gap-1.5 text-sm text-slate-400">
<CalendarRange className="h-3.5 w-3.5" />
{wochenTitel(woche)}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wählen">
<Link
href={`/rueckblick?woche=${versatz - 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche zurück"
// Am Ende des Zeitraums bleibt der Verweis stehen, wird aber
// wirkungslos – die Seite klemmt den Wert ohnehin.
aria-disabled={versatz <= -MAX_RUECKBLICK}
>
<ArrowLeft className="h-3.5 w-3.5" />
Früher
</Link>
{versatz < 0 ? (
<Link
href={`/rueckblick?woche=${versatz + 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche vor"
>
Später
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
{versatz !== 0 ? (
<Link href="/rueckblick" className="btn btn-ghost px-2.5 py-1.5 text-xs">
Diese Woche
</Link>
) : null}
<Link href="/verlauf" className="btn btn-ghost px-2.5 py-1.5 text-xs">
<History className="h-3.5 w-3.5" />
Ganzer Verlauf
</Link>
</nav>
</div>
</header>
<WeeklyReview
tage={tage}
kennzahlen={kennzahlen}
istDieseWoche={versatz === 0}
/>
</div>
);
}
+1
View File
@@ -42,6 +42,7 @@ export default async function TasksPage() {
dueDate: task.dueDate ? task.dueDate.toISOString() : null, dueDate: task.dueDate ? task.dueDate.toISOString() : null,
labels: task.labels, labels: task.labels,
recurrence: task.recurrence, recurrence: task.recurrence,
sourceIssue: task.sourceIssue,
sourceUrl: task.sourceUrl, sourceUrl: task.sourceUrl,
bucket: dueBucket(task.dueDate, jetzt), bucket: dueBucket(task.dueDate, jetzt),
project: task.project, project: task.project,
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CalendarRange, History } from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { SEITE, SEITEN_ORDNUNG, seitenBedingung } from "@/lib/timeline";
import type { ReviewEntry } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Verlauf" };
/**
* Der Verlauf ueber alle Projekte, fortlaufend.
*
* Die erste Seite kommt serverseitig mit, damit sofort etwas dasteht; alles
* Weitere holt der Knopf per Cursor nach. Anders als der Wochenrueckblick
* hat diese Seite keine Grenze und kein Fazit – sie laeuft zurueck, solange
* es Eintraege gibt.
*/
export default async function TimelinePage() {
const user = await requireUserPage();
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(user.id, null),
orderBy: SEITEN_ORDNUNG,
// Einen mehr, um zu wissen, ob es weitergeht.
take: SEITE + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > SEITE;
const erste: ReviewEntry[] = (weitere ? gefunden.slice(0, SEITE) : gefunden).map((eintrag) => ({
id: eintrag.id,
kind: eintrag.kind,
summary: eintrag.summary,
createdAt: eintrag.createdAt.toISOString(),
project: eintrag.project,
user: eintrag.user,
}));
return (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
<History className="h-6 w-6 text-violet-300" />
Verlauf
</h1>
<p className="mt-1 text-sm text-slate-400">
Alles, was passiert ist – vom Neuesten rückwärts.
</p>
</div>
<Link href="/rueckblick" className="btn btn-ghost px-3 py-1.5 text-xs">
<CalendarRange className="h-3.5 w-3.5" />
Wochenrückblick
</Link>
</div>
</header>
<Timeline erste={erste} weitere={weitere} />
</div>
);
}
+4 -33
View File
@@ -1,23 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { import { Bot, History, Loader2 } from "lucide-react";
AlertTriangle, import { ACTIVITY_KINDS } from "@/components/activityKinds";
ArrowRight, import type { ActivityEntry } from "@/types";
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"; import { formatDateTime, formatRelative } from "@/lib/utils";
/** /**
@@ -29,21 +15,6 @@ import { formatDateTime, formatRelative } from "@/lib/utils";
* obersten fuenf liest. * 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({ export function ActivityFeed({
projectId, projectId,
@@ -88,7 +59,7 @@ export function ActivityFeed({
) : ( ) : (
<ol className="mt-4 space-y-0"> <ol className="mt-4 space-y-0">
{entries.map((entry, index) => { {entries.map((entry, index) => {
const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED; const meta = ACTIVITY_KINDS[entry.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = meta.icon; const Icon = meta.icon;
const letzter = index === entries.length - 1; const letzter = index === entries.length - 1;
+126
View File
@@ -0,0 +1,126 @@
import Link from "next/link";
import { ACTIVITY_KINDS } from "@/components/activityKinds";
import { accentOf } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Darstellung von Verlaufseintraegen – eine Zeile und die Gruppierung nach
* Kalendertagen.
*
* Bewusst ohne "use client" und ohne Hooks: so laesst sich beides sowohl aus
* einer Server-Komponente (Wochenrueckblick) als auch aus einer
* Client-Komponente (Zeitleiste mit Nachladen) heraus benutzen. Zwei Kopien
* derselben Zeile waeren beim naechsten Feinschliff auseinandergelaufen.
*
* Alle Formate mit fester Zeitzone – dieselbe Ueberlegung wie in lib/utils:
* sonst weichen Server-Render und Hydration voneinander ab.
*/
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
weekday: "long",
});
const TAG_MONAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
});
const TAG_MONAT_JAHR = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
year: "numeric",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
/** "2026-08-17" → ein Zeitpunkt mittags, damit die Zeitzone nichts verschiebt. */
function alsDatum(tag: string): Date {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t, 12));
}
export function ActivityDayGroups({
tage,
/** Bei Tagen aus anderen Jahren gehoert die Jahreszahl dazu. */
mitJahr = false,
}: {
tage: ReviewDay[];
mitJahr?: boolean;
}) {
const format = mitJahr ? TAG_MONAT_JAHR : TAG_MONAT;
return (
<div className="space-y-6">
{tage.map((tag) => {
const datum = alsDatum(tag.tag);
return (
<section key={tag.tag}>
<div className="mb-2.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{WOCHENTAG.format(datum)}
</h2>
<span className="text-xs text-slate-500">{format.format(datum)}</span>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{tag.eintraege.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{tag.eintraege.map((eintrag) => (
<ActivityRow key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
);
}
export function ActivityRow({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = art.icon;
const accent = accentOf(eintrag.project.accent);
return (
<li className="flex items-start gap-2.5 px-3 py-2.5 sm:gap-3 sm:px-4">
<span className={cn("mt-0.5 shrink-0", art.color)} title={art.label}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{eintrag.summary}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${eintrag.project.id}`}
className="inline-flex max-w-[12rem] items-center gap-1.5 truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accent.from }}
aria-hidden="true"
/>
{eintrag.project.name}
</Link>
<span className="tabular-nums">{UHRZEIT.format(new Date(eintrag.createdAt))} Uhr</span>
{eintrag.user ? (
<span className="hidden xs:inline">
{eintrag.user.displayName?.trim() || eintrag.user.username}
</span>
) : (
// Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
<span className="hidden text-slate-600 xs:inline">automatisch</span>
)}
</div>
</div>
</li>
);
}
+64 -5
View File
@@ -5,9 +5,11 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Bell, Bell,
CalendarRange,
CheckSquare, CheckSquare,
CornerDownLeft, CornerDownLeft,
GitBranch, GitBranch,
History,
LayoutDashboard, LayoutDashboard,
Loader2, Loader2,
NotebookPen, NotebookPen,
@@ -16,8 +18,9 @@ import {
Settings, Settings,
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus, TaskStatus } from "@prisma/client";
import { statusOf } from "@/lib/status"; import { statusOf } from "@/lib/status";
import { TASK_STATUS_META } from "@/lib/tasks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
@@ -38,6 +41,14 @@ interface ProjectHit {
tags: string[]; tags: string[];
} }
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
snippet: string;
}
interface NoteHit { interface NoteHit {
projectId: string; projectId: string;
noteId: string; noteId: string;
@@ -56,6 +67,20 @@ interface Action {
const ACTIONS: Action[] = [ const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" }, { id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
{
id: "rueckblick",
label: "Wochenrückblick",
hint: "Was diese Woche passiert ist",
icon: CalendarRange,
href: "/rueckblick",
},
{
id: "verlauf",
label: "Verlauf",
hint: "Alles, was passiert ist – über alle Projekte",
icon: History,
href: "/verlauf",
},
{ {
id: "tasks", id: "tasks",
label: "Aufgaben", label: "Aufgaben",
@@ -91,7 +116,8 @@ const ACTIONS: Action[] = [
type Entry = type Entry =
| { kind: "action"; key: string; action: Action } | { kind: "action"; key: string; action: Action }
| { kind: "project"; key: string; project: ProjectHit } | { kind: "project"; key: string; project: ProjectHit }
| { kind: "note"; key: string; note: NoteHit; projectName: string }; | { kind: "note"; key: string; note: NoteHit; projectName: string }
| { kind: "task"; key: string; task: TaskHit; projectName: string };
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const router = useRouter(); const router = useRouter();
@@ -100,6 +126,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [projects, setProjects] = useState<ProjectHit[] | null>(null); const [projects, setProjects] = useState<ProjectHit[] | null>(null);
const [notes, setNotes] = useState<NoteHit[]>([]); const [notes, setNotes] = useState<NoteHit[]>([]);
const [aufgaben, setAufgaben] = useState<TaskHit[]>([]);
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
@@ -138,6 +165,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const needle = query.trim(); const needle = query.trim();
if (needle.length < 2) { if (needle.length < 2) {
setNotes([]); setNotes([]);
setAufgaben([]);
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
@@ -147,8 +175,9 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) return; if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] }; const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] };
setNotes(body.hits ?? []); setNotes(body.hits ?? []);
setAufgaben(body.tasks ?? []);
} catch { } catch {
// Abbruch beim Weitertippen ist der Normalfall. // Abbruch beim Weitertippen ist der Normalfall.
} }
@@ -163,6 +192,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
if (!open) { if (!open) {
setQuery(""); setQuery("");
setNotes([]); setNotes([]);
setAufgaben([]);
setActive(0); setActive(0);
} }
}, [open]); }, [open]);
@@ -202,6 +232,17 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
for (const project of passendeProjekte.slice(0, 8)) { for (const project of passendeProjekte.slice(0, 8)) {
result.push({ kind: "project", key: `p-${project.id}`, project }); result.push({ kind: "project", key: `p-${project.id}`, project });
} }
// Aufgaben vor Notizen: wer tippt, sucht haeufiger etwas zu Tun als
// etwas Notiertes.
for (const treffer of aufgaben.slice(0, 5)) {
result.push({
kind: "task",
key: `t-${treffer.taskId}`,
task: treffer,
projectName:
(projects ?? []).find((project) => project.id === treffer.projectId)?.name ?? "Projekt",
});
}
for (const hit of notes.slice(0, 5)) { for (const hit of notes.slice(0, 5)) {
result.push({ result.push({
kind: "note", kind: "note",
@@ -215,7 +256,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
result.push({ kind: "action", key: `a-${action.id}`, action }); result.push({ kind: "action", key: `a-${action.id}`, action });
} }
return result; return result;
}, [query, projects, notes, isAdmin]); }, [query, projects, notes, aufgaben, isAdmin]);
useEffect(() => { useEffect(() => {
setActive((current) => Math.min(current, Math.max(0, entries.length - 1))); setActive((current) => Math.min(current, Math.max(0, entries.length - 1)));
@@ -226,6 +267,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
setOpen(false); setOpen(false);
if (entry.kind === "action") router.push(entry.action.href); if (entry.kind === "action") router.push(entry.action.href);
else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`); else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`);
else if (entry.kind === "task") router.push(`/projects/${entry.task.projectId}`);
else router.push(`/projects/${entry.note.projectId}`); else router.push(`/projects/${entry.note.projectId}`);
}, },
[router], [router],
@@ -276,7 +318,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKey} onKeyDown={onInputKey}
placeholder="Projekt, Notiz oder Bereich…" placeholder="Projekt, Aufgabe, Notiz oder Bereich…"
className="w-full bg-transparent text-sm text-slate-100 outline-none placeholder:text-slate-600" 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} {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-slate-600" /> : null}
@@ -330,6 +372,23 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
{statusOf(entry.project.status).short} {statusOf(entry.project.status).short}
</span> </span>
</> </>
) : entry.kind === "task" ? (
<>
<CheckSquare className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300/70" />
<span className="min-w-0">
<span className="block truncate text-sm text-slate-200">
{entry.task.title}
<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.task.snippet.replace(/\[\[|\]\]/g, "").trim() ||
TASK_STATUS_META[entry.task.status as TaskStatus]?.label ||
"Aufgabe"}
</span>
</span>
</>
) : ( ) : (
<> <>
<NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" /> <NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" />
+36
View File
@@ -17,6 +17,7 @@ import {
Lock, Lock,
Package, Package,
Plus, Plus,
Radio,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
Star, Star,
@@ -26,6 +27,7 @@ import {
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap"; import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog"; import { RepositoryDialog } from "@/components/RepositoryDialog";
import { WebhookDialog } from "@/components/WebhookDialog";
import { Sparkline } from "@/components/Sparkline"; import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta"; import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types"; import { asArray, asCalendar, asRecord } from "@/types";
@@ -55,9 +57,13 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({ export function GitPanel({
project, project,
connections, connections,
webhookRepoIds = [],
}: { }: {
project: ProjectDetail; project: ProjectDetail;
connections: ConnectionSafe[]; connections: ConnectionSafe[];
/** Repositories mit hinterlegtem Webhook. Das Geheimnis selbst bleibt
auf dem Server – hier kommt nur an, ob eines existiert. */
webhookRepoIds?: string[];
}) { }) {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>("pulls"); const [tab, setTab] = useState<TabKey>("pulls");
@@ -65,6 +71,7 @@ export function GitPanel({
const [importing, setImporting] = 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 [addOpen, setAddOpen] = useState(false);
const [webhookOpen, setWebhookOpen] = useState(false);
const repositories = project.repositories; const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null); const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
@@ -72,6 +79,7 @@ export function GitPanel({
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen. // zurueckfallen, statt eine leere Ansicht zu zeigen.
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false;
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
async function importIssues() { async function importIssues() {
@@ -280,6 +288,24 @@ export function GitPanel({
Repository Repository
</button> </button>
)} )}
<button
type="button"
onClick={() => setWebhookOpen(true)}
className={cn(
"rounded-lg p-2 transition-colors",
webhookAktiv
? "text-emerald-300 hover:bg-emerald-500/10"
: "text-slate-500 hover:bg-white/8 hover:text-violet-300",
)}
aria-label="Webhook einrichten"
title={
webhookAktiv
? "Webhook ist eingerichtet – meldet Aenderungen sofort"
: "Webhook einrichten – Aenderungen sofort statt beim naechsten Abgleich"
}
>
<Radio className="h-4 w-4" />
</button>
<button <button
type="button" type="button"
onClick={() => removeRepository(repository)} onClick={() => removeRepository(repository)}
@@ -312,6 +338,16 @@ export function GitPanel({
connections={connections} connections={connections}
/> />
<WebhookDialog
open={webhookOpen}
onClose={() => setWebhookOpen(false)}
repositoryId={repository.id}
repositoryTitle={repoTitle(repository)}
provider={repository.connection?.provider ?? null}
eingerichtet={webhookAktiv}
zuletzt={repository.webhookLastAt}
/>
{message ? ( {message ? (
<p <p
className={cn( className={cn(
+76 -5
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { import {
CheckSquare,
Columns3, Columns3,
Database, Database,
Download, Download,
@@ -16,7 +17,7 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus, TaskStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar"; import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog"; import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard"; import { KanbanBoard } from "@/components/KanbanBoard";
@@ -25,9 +26,18 @@ import { ProjectDialog } from "@/components/ProjectDialog";
import { ProjectList } from "@/components/ProjectList"; import { ProjectList } from "@/components/ProjectList";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status"; import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { TASK_STATUS_META } from "@/lib/tasks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types"; import type { ConnectionSafe, ProjectListItem } from "@/types";
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
snippet: string;
}
interface NoteHit { interface NoteHit {
projectId: string; projectId: string;
noteId: string; noteId: string;
@@ -135,6 +145,7 @@ export function ProjectBoard({
const [dataMenuOpen, setDataMenuOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null); const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]); const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [taskHits, setTaskHits] = useState<TaskHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set()); const [selection, setSelection] = useState<Set<string>>(new Set());
/** /**
@@ -147,6 +158,7 @@ export function ProjectBoard({
const needle = query.trim(); const needle = query.trim();
if (needle.length < 2) { if (needle.length < 2) {
setNoteHits([]); setNoteHits([]);
setTaskHits([]);
return; return;
} }
@@ -157,8 +169,9 @@ export function ProjectBoard({
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) return; if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] }; const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] };
setNoteHits(body.hits ?? []); setNoteHits(body.hits ?? []);
setTaskHits(body.tasks ?? []);
} catch { } catch {
// Abgebrochene Anfragen sind der Normalfall beim Tippen. // Abgebrochene Anfragen sind der Normalfall beim Tippen.
} }
@@ -171,8 +184,12 @@ export function ProjectBoard({
}, [query]); }, [query]);
const noteHitProjectIds = useMemo( const noteHitProjectIds = useMemo(
() => new Set(noteHits.map((hit) => hit.projectId)), () =>
[noteHits], new Set([
...noteHits.map((hit) => hit.projectId),
...taskHits.map((hit) => hit.projectId),
]),
[noteHits, taskHits],
); );
const counts = useMemo(() => { const counts = useMemo(() => {
@@ -200,7 +217,7 @@ export function ProjectBoard({
project.repositories.some((repo) => project.repositories.some((repo) =>
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle), (repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
) || ) ||
// Treffer in Notizen kommen vom Server dazu. // Treffer in Notizen und Aufgaben kommen vom Server dazu.
noteHitProjectIds.has(project.id) noteHitProjectIds.has(project.id)
); );
}); });
@@ -434,6 +451,60 @@ export function ProjectBoard({
</div> </div>
</div> </div>
{/* Treffer in Aufgaben */}
{taskHits.length > 0 ? (
<div className="glass fade-up p-4">
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<CheckSquare className="h-3.5 w-3.5 text-emerald-300" />
Treffer in Aufgaben
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
{taskHits.length}
</span>
</h2>
<ul className="space-y-1">
{taskHits.slice(0, 8).map((hit) => {
const project = projects.find((entry) => entry.id === hit.projectId);
const status = TASK_STATUS_META[hit.status as TaskStatus];
return (
<li key={hit.taskId}>
<Link
href={`/projects/${hit.projectId}`}
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
>
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm text-slate-200">{hit.title}</span>
<span className="text-[11px] text-slate-500">
· {project?.name ?? "Projekt"}
</span>
{status ? (
<span
className="inline-flex items-center gap-1 text-[11px]"
style={{ color: status.color }}
>
{status.label}
</span>
) : null}
</span>
{/* Nur wenn die Beschreibung etwas beitraegt – passte
allein der Titel, ist der Ausschnitt leer. */}
{hit.snippet.trim() ? (
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
{highlight(hit.snippet)}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
{taskHits.length > 8 ? (
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
… und {taskHits.length - 8} weitere
</p>
) : null}
</div>
) : null}
{/* Treffer in Notizen */} {/* Treffer in Notizen */}
{noteHits.length > 0 ? ( {noteHits.length > 0 ? (
<div className="glass fade-up p-4"> <div className="glass fade-up p-4">
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, History, Loader2 } from "lucide-react";
import { ActivityDayGroups } from "@/components/ActivityRow";
import { tagesschluessel } from "@/lib/review";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Die fortlaufende Zeitleiste.
*
* Client-Komponente, weil hier nachgeladen wird – das ist der Unterschied
* zum Wochenrueckblick, der komplett auf dem Server entsteht.
*
* Die Buendelung nach Tagen passiert hier im Browser. Das ist unbedenklich,
* weil tagesschluessel eine feste Zeitzone benutzt: Server und Browser kommen
* damit auf denselben Kalendertag, und die erste Seite hydriert ohne
* Abweichung.
*/
export function Timeline({
erste,
weitere: gabWeitere,
}: {
erste: ReviewEntry[];
weitere: boolean;
}) {
const [eintraege, setEintraege] = useState(erste);
const [weitere, setWeitere] = useState(gabWeitere);
const [laedt, setLaedt] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const tage: ReviewDay[] = useMemo(() => {
// Map behaelt die Einfuegereihenfolge – die Eintraege kommen schon
// absteigend, also stimmt die Reihenfolge der Tage von selbst.
const nachTag = new Map<string, ReviewEntry[]>();
for (const eintrag of eintraege) {
const schluessel = tagesschluessel(new Date(eintrag.createdAt));
const liste = nachTag.get(schluessel) ?? [];
liste.push(eintrag);
nachTag.set(schluessel, liste);
}
return [...nachTag.entries()].map(([tag, liste]) => ({ tag, eintraege: liste }));
}, [eintraege]);
async function mehr() {
const letzter = eintraege[eintraege.length - 1];
if (!letzter) return;
setLaedt(true);
setFehler(null);
try {
const antwort = await fetch(
`/api/activity?vor=${encodeURIComponent(letzter.createdAt)}&id=${encodeURIComponent(letzter.id)}`,
);
if (!antwort.ok) throw new Error("Verlauf konnte nicht geladen werden.");
const nutzlast = (await antwort.json()) as {
activities: ReviewEntry[];
weitere: boolean;
};
/**
* Gegen Dubletten absichern, auch wenn der Cursor sie ausschliesst:
* ein zweimal gedrueckter Knopf oder ein wiederholter Aufruf soll
* keine Zeile verdoppeln, und React braucht eindeutige Schluessel.
*/
setEintraege((bisher) => {
const bekannt = new Set(bisher.map((eintrag) => eintrag.id));
return [...bisher, ...nutzlast.activities.filter((eintrag) => !bekannt.has(eintrag.id))];
});
setWeitere(nutzlast.weitere);
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setLaedt(false);
}
}
if (eintraege.length === 0) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<History className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">Noch kein Verlauf</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
Sobald du ein Projekt anlegst, Aufgaben verschiebst oder Notizen schreibst, steht es
hier.
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Die Jahreszahl gehoert dazu: eine Zeitleiste reicht weit zurueck,
und "3. Januar" allein sagt dann nicht genug. */}
<ActivityDayGroups tage={tage} mitJahr />
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
<div className="flex flex-col items-center gap-2 pt-1">
{weitere ? (
<button type="button" className="btn btn-ghost" onClick={mehr} disabled={laedt}>
{laedt ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Ältere laden
</button>
) : (
<p className="text-xs text-slate-600">Das war alles.</p>
)}
<p className="text-[11px] text-slate-600 tabular-nums">
{eintraege.length} {eintraege.length === 1 ? "Vorgang" : "Vorgänge"} geladen
</p>
</div>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Copy, Loader2, Radio, Trash2 } from "lucide-react";
import type { GitProvider } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { formatRelative } from "@/lib/utils";
/**
* Einrichtung des Webhooks fuer ein Repository.
*
* Der Haken wird im Git-System von Hand eingetragen. Ihn ueber die API
* anzulegen waere machbar, verlangte aber Administrationsrechte am
* Repository – deutlich mehr, als Nebula sonst braucht, und es waere ein
* Recht, das die meisten Token gar nicht haben.
*
* Das Geheimnis ist genau einmal zu sehen: danach liegt nur noch die
* verschluesselte Fassung in der Datenbank, wie bei den Zugangstoken.
*/
/** Wo der Haken im jeweiligen System einzutragen ist. */
const ANLEITUNG: Record<GitProvider, { pfad: string; ereignisse: string }> = {
GITHUB: {
pfad: "Settings → Webhooks → Add webhook",
ereignisse: "Content type „application/json“, Events: Pushes und Issues",
},
GITEA: {
pfad: "Einstellungen → Webhooks → Gitea",
ereignisse: "Auslöser: Push und Issues",
},
GITLAB: {
pfad: "Settings → Webhooks",
ereignisse: "Trigger: Push events und Issues events",
},
};
export function WebhookDialog({
open,
onClose,
repositoryId,
repositoryTitle,
provider,
eingerichtet,
zuletzt,
}: {
open: boolean;
onClose: () => void;
repositoryId: string;
repositoryTitle: string;
provider: GitProvider | null;
eingerichtet: boolean;
zuletzt: Date | string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const [geheimnis, setGeheimnis] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [kopiert, setKopiert] = useState<string | null>(null);
// Ohne Verbindung ist das System unbekannt. Dann lieber allgemein bleiben,
// als eine Anleitung fuer das falsche zu zeigen.
const anleitung = provider ? ANLEITUNG[provider] : null;
async function erzeugen() {
setBusy(true);
setFehler(null);
try {
const antwort = await fetch(`/api/repositories/${repositoryId}/webhook`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Fehlgeschlagen.");
setGeheimnis(nutzlast.secret);
setUrl(nutzlast.url);
router.refresh();
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setBusy(false);
}
}
async function zuruecknehmen() {
if (!window.confirm("Webhook zuruecknehmen? Eingehende Aufrufe werden dann abgewiesen.")) {
return;
}
setBusy(true);
try {
await fetch(`/api/repositories/${repositoryId}/webhook`, { method: "DELETE" });
setGeheimnis(null);
setUrl(null);
router.refresh();
onClose();
} finally {
setBusy(false);
}
}
async function kopieren(wert: string, was: string) {
try {
await navigator.clipboard.writeText(wert);
setKopiert(was);
setTimeout(() => setKopiert(null), 2000);
} catch {
// Ohne Zwischenablage bleibt der Text zum Markieren stehen.
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Webhook einrichten"
description={`Damit meldet ${repositoryTitle} Aenderungen sofort, statt auf den naechsten Abgleich zu warten.`}
>
<div className="space-y-4">
{eingerichtet && !geheimnis ? (
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2.5">
<p className="flex items-center gap-2 text-sm text-emerald-100">
<Radio className="h-4 w-4 shrink-0" />
Webhook ist eingerichtet.
</p>
<p className="mt-1 text-xs text-emerald-300/70" suppressHydrationWarning>
{zuletzt
? `Zuletzt empfangen ${formatRelative(zuletzt)}.`
: "Bisher ist noch kein Aufruf angekommen — der Haken im Git-System fehlt vermutlich noch."}
</p>
</div>
) : null}
{geheimnis && url ? (
<div className="space-y-3">
<Feld label="URL" wert={url} kopiert={kopiert === "url"} onCopy={() => kopieren(url, "url")} />
<Feld
label="Geheimnis (Secret)"
wert={geheimnis}
kopiert={kopiert === "secret"}
onCopy={() => kopieren(geheimnis, "secret")}
/>
<p className="rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-xs text-amber-200">
Das Geheimnis ist <strong>jetzt zum einzigen Mal</strong> zu sehen. Danach liegt es
nur noch verschluesselt in der Datenbank. Verloren? Dann hier ein neues erzeugen —
das alte gilt dann nicht mehr.
</p>
</div>
) : null}
<div className="rounded-lg border border-white/8 bg-white/2 px-3 py-2.5 text-xs text-slate-400">
<p className="font-semibold text-slate-300">Im Git-System eintragen</p>
{anleitung ? (
<>
<p className="mt-1">{anleitung.pfad}</p>
<p className="mt-0.5">{anleitung.ereignisse}</p>
</>
) : (
<p className="mt-1">
In den Einstellungen des Repositories unter „Webhooks“, mit den Ausloesern
Push und Issues. Ist eine Git-Verbindung hinterlegt, steht hier der genaue
Pfad fuer das jeweilige System.
</p>
)}
<p className="mt-2 text-slate-500">
Nebula wertet nur Push und Issues aus. Alles andere wird angenommen und liegen
gelassen — ein Fehler wuerde das Git-System sonst zum Wiederholen bringen.
</p>
</div>
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
{eingerichtet ? (
<button
type="button"
className="btn btn-danger px-3 py-1.5 text-xs"
onClick={zuruecknehmen}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Zuruecknehmen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
{geheimnis ? "Fertig" : "Schliessen"}
</button>
<button type="button" className="btn btn-primary" onClick={erzeugen} disabled={busy}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Radio className="h-4 w-4" />}
{eingerichtet ? "Neues Geheimnis" : "Einrichten"}
</button>
</div>
</div>
</div>
</Modal>
);
}
function Feld({
label,
wert,
kopiert,
onCopy,
}: {
label: string;
wert: string;
kopiert: boolean;
onCopy: () => void;
}) {
return (
<div>
<span className="field-label">{label}</span>
<div className="flex items-center gap-2">
{/* tabIndex: der Kasten scrollt seitlich und muss deshalb auch mit
der Tastatur erreichbar sein – sonst kommt man an das Ende einer
langen URL nur mit der Maus. */}
<code
tabIndex={0}
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/9 bg-[rgba(8,10,24,0.6)] px-3 py-2 font-mono text-xs text-slate-200"
>
{wert}
</code>
<button
type="button"
onClick={onCopy}
className="btn btn-ghost shrink-0 px-2.5 py-2"
aria-label={`${label} kopieren`}
>
{kopiert ? <Check className="h-4 w-4 text-emerald-300" /> : <Copy className="h-4 w-4" />}
</button>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react";
import { ActivityDayGroups } from "@/components/ActivityRow";
import type { Kennzahlen } from "@/lib/review";
import type { ReviewDay } from "@/types";
/**
* Der Wochenrueckblick.
*
* Bewusst eine Server-Komponente: es gibt nichts zu bedienen, alles steht
* beim Rendern fest. Das spart dem Browser den ganzen Verlauf als JavaScript.
*
* Die Tage kommen fertig gebuendelt und in der richtigen Reihenfolge – die
* Zuordnung "welcher Kalendertag" haengt an der Zeitzone und gehoert
* deshalb auf den Server.
*/
export function WeeklyReview({
tage,
kennzahlen,
istDieseWoche,
}: {
tage: ReviewDay[];
/** Fertig gezaehlt vom Server – die Anzeige rechnet nichts nach. */
kennzahlen: Kennzahlen;
istDieseWoche: boolean;
}) {
const { gesamt, projekte, erledigt, neueAufgaben, notizen } = kennzahlen;
if (gesamt === 0) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<CalendarRange className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{istDieseWoche ? "Diese Woche war es still" : "In dieser Woche ist nichts passiert"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{istDieseWoche
? "Sobald du Aufgaben abhakst, Notizen schreibst oder etwas gepusht wird, steht es hier."
: "Vielleicht lohnt ein Blick auf eine frühere Woche."}
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<Kennzahl label="Vorgänge" wert={gesamt} icon={CalendarRange} color="#a78bfa" />
<Kennzahl label="Projekte berührt" wert={projekte} icon={FolderGit2} color="#38bdf8" />
<Kennzahl label="Aufgaben erledigt" wert={erledigt} icon={CheckSquare} color="#34d399" />
<Kennzahl label="Neue Aufgaben" wert={neueAufgaben} icon={Plus} color="#fbbf24" />
</div>
{notizen > 0 ? (
<p className="flex items-center gap-1.5 px-1 text-xs text-slate-500">
<NotebookPen className="h-3.5 w-3.5" />
Dazu {notizen} {notizen === 1 ? "neue Notiz" : "neue Notizen"}.
</p>
) : null}
{/* Verlauf nach Tagen – dieselbe Darstellung wie in der Zeitleiste. */}
<ActivityDayGroups tage={tage} />
</div>
);
}
function Kennzahl({
label,
wert,
icon: Icon,
color,
}: {
label: string;
wert: number;
icon: typeof CalendarRange;
color: string;
}) {
return (
<div className="glass relative overflow-hidden p-3 sm:p-4">
<div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label}
</p>
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:text-3xl">
{wert}
</p>
</div>
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }}
>
<Icon className="h-4 w-4" />
</span>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import {
AlertTriangle,
ArrowRight,
Download,
GitCommitHorizontal,
Link2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityKind } from "@prisma/client";
/**
* Symbol und Farbe je Art eines Verlaufseintrags.
*
* Ausgelagert, weil sie inzwischen an zwei Stellen gebraucht werden: im
* Verlauf eines Projekts und im Wochenrueckblick ueber alle Projekte. Zwei
* Kopien liefen beim naechsten neuen Eintragstyp auseinander.
*
* Bewusst ohne "use client" und ohne Prisma-Zugriff – nur Typen und
* Symbole, damit beide Seiten es importieren koennen.
*/
export const ACTIVITY_KINDS: Record<
ActivityKind,
{ icon: typeof Sparkles; color: string; label: string }
> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300", label: "Projekt angelegt" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400", label: "Projekt bearbeitet" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300", label: "Status geändert" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300", label: "Notiz" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300", label: "Notiz bearbeitet" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300", label: "Notiz gelöscht" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300", label: "Aufgabe angelegt" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300", label: "Aufgabe verschoben" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300", label: "Aufgabe gelöscht" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300", label: "Aufgaben übernommen" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300", label: "Repository verknüpft" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300", label: "Commits" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300", label: "Abgleich fehlgeschlagen" },
};
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { Task } from "@prisma/client";
import { TaskCard } from "./TaskCard";
/**
* Die Karte ist die einzige Stelle im Projekt, an der eine Aufgabe im
* Vorbeigehen zu sehen ist – der Bearbeiten-Dialog verlangt einen Klick.
* Steht die Verknuepfung nur dort, weiss niemand vorher, welche Aufgabe
* ein Issue hat.
*
* Serverseitig gerendert wie bei Markdown.test.tsx: es geht um das
* erzeugte Markup, nicht um Verhalten im Browser.
*/
function aufgabe(felder: Partial<Task> = {}): Task {
return {
id: "t1",
projectId: "p1",
title: "Etwas tun",
description: null,
status: "TODO",
position: 0,
dueDate: null,
labels: [],
recurrence: null,
sourceIssue: null,
sourceUrl: null,
sourceRepositoryId: null,
createdAt: new Date("2026-08-18T10:00:00Z"),
updatedAt: new Date("2026-08-18T10:00:00Z"),
...felder,
} as Task;
}
const markup = (task: Task) => renderToStaticMarkup(<TaskCard task={task} />);
describe("Kennzeichen fuer ein verknuepftes Issue", () => {
it("zeigt die Nummer, sobald eine Aufgabe an einem Issue haengt", () => {
const html = markup(aufgabe({ sourceIssue: 42 }));
expect(html).toContain("#42");
expect(html).toContain("Verkn");
});
it("erscheint auch ohne Beschreibung, Datum und Labels", () => {
// Der eigentliche Fallstrick: die Fusszeile der Karte wird nur
// gerendert, wenn es etwas zu zeigen gibt. Faellt die Verknuepfung aus
// dieser Bedingung heraus, bleibt genau die nackte Aufgabe unbeschriftet
// – und das ist der haeufige Fall bei einer aus einem Issue erzeugten.
const html = markup(
aufgabe({ sourceIssue: 7, description: null, dueDate: null, labels: [], recurrence: null }),
);
expect(html).toContain("#7");
});
it("bleibt weg, wenn keine Verknuepfung besteht", () => {
const html = markup(aufgabe({ description: "Text" }));
expect(html).not.toContain("Verkn");
});
it("bleibt auf einer erledigten Aufgabe sichtbar", () => {
// Die erledigte Karte streicht ihren Titel durch; das Kennzeichen bleibt
// in voller Helligkeit. Abzudunkeln war versucht und wieder verworfen –
// axe-core rechnete es als Kontrastverstoss ab (10px Text, 3,2:1).
// Dass hier ein Issue mitgeschlossen wurde, ist ohnehin gerade dann die
// interessante Auskunft.
const html = markup(aufgabe({ sourceIssue: 5, status: "DONE" }));
expect(html).toContain("#5");
});
it("baut keinen Link in die Karte", () => {
// Die Karte steckt in einem <button>; ein <a> darin waere ungueltiges
// Markup und im Browser nicht verlaesslich anklickbar.
const html = markup(aufgabe({ sourceIssue: 42, sourceUrl: "https://example.com/issues/42" }));
expect(html).not.toContain("<a ");
});
it("nennt die Verknuepfung auch fuer Vorlesesoftware", () => {
// "#42" allein ergibt vorgelesen keinen Sinn.
const html = markup(aufgabe({ sourceIssue: 42 }));
expect(html).toMatch(/sr-only[^>]*>Verkn/);
});
});
+26 -2
View File
@@ -1,6 +1,12 @@
"use client"; "use client";
import { AlignLeft, CalendarClock, GripVertical, Repeat } from "lucide-react"; import {
AlignLeft,
CalendarClock,
GitPullRequestArrow,
GripVertical,
Repeat,
} from "lucide-react";
import type { Task } from "@prisma/client"; import type { Task } from "@prisma/client";
import { RECURRENCE_META } from "@/lib/tasks"; import { RECURRENCE_META } from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
@@ -70,8 +76,26 @@ export function TaskCard({
{task.title} {task.title}
</p> </p>
{(task.description || task.dueDate || task.recurrence || task.labels.length > 0) && ( {(task.description ||
task.dueDate ||
task.recurrence ||
task.sourceIssue ||
task.labels.length > 0) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]"> <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{/* Steht vorn, damit sich eine Spalte danach ueberfliegen laesst.
Bewusst kein Link: die Karte steckt in einem <button>, und ein
<a> darin waere ungueltig. Der Weg zum Issue fuehrt ueber den
Bearbeiten-Dialog, der ihn ohnehin anbietet. */}
{task.sourceIssue ? (
<span
className="inline-flex items-center gap-1 text-violet-300"
title={`Verknüpft mit Issue #${task.sourceIssue}`}
>
<GitPullRequestArrow className="h-2.5 w-2.5" aria-hidden />
<span className="sr-only">Verknüpft mit </span>#{task.sourceIssue}
</span>
) : null}
{task.description ? ( {task.description ? (
<AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" /> <AlignLeft className="h-2.5 w-2.5 text-slate-600" aria-label="hat Beschreibung" />
) : null} ) : null}
+100 -3
View File
@@ -2,12 +2,33 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react"; import { ExternalLink, GitPullRequestArrow, Loader2, Trash2 } from "lucide-react";
import type { Recurrence, Task, TaskStatus } from "@prisma/client"; import type { Recurrence, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
import { cn, parseTags } from "@/lib/utils"; import { cn, parseTags } from "@/lib/utils";
/**
* Was der Dialog von einer Aufgabe tatsaechlich liest.
*
* Bewusst nicht der vollstaendige Prisma-Datensatz: die Aufgabenuebersicht
* ueber alle Projekte reicht eine schlankere Fassung durch, in der das Datum
* als Zeichenkette ankommt – ein Date ueberlebt den Weg vom Server zum
* Browser nicht. Ein echtes Task erfuellt diese Form ebenfalls.
*/
export interface EditableTask {
id: string;
title: string;
description: string | null;
status: TaskStatus;
dueDate: Date | string | null;
labels: string[];
recurrence: Recurrence | null;
/** Gesetzt, sobald die Aufgabe an einem Git-Issue haengt. */
sourceIssue?: number | null;
sourceUrl?: string | null;
}
/** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */ /** Datum fuer <input type="date"> – ortszeitbezogen, nicht UTC. */
function toDateInput(value: Date | string | null): string { function toDateInput(value: Date | string | null): string {
if (!value) return ""; if (!value) return "";
@@ -26,7 +47,7 @@ export function TaskDialog({
}: { }: {
open: boolean; open: boolean;
projectId: string; projectId: string;
task: Task | null; task: EditableTask | null;
initialStatus: TaskStatus; initialStatus: TaskStatus;
onClose: () => void; onClose: () => void;
}) { }) {
@@ -38,10 +59,38 @@ export function TaskDialog({
const [labels, setLabels] = useState(""); const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>(""); const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [issueBusy, setIssueBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isEdit = Boolean(task); const isEdit = Boolean(task);
/**
* Legt zur Aufgabe ein Issue im verknuepften Repository an.
*
* Ausdruecklich per Knopf und nicht beim Speichern: was in ein fremdes
* System geschrieben wird, soll jemand gewollt haben.
*/
async function createIssue() {
if (!task) return;
setIssueBusy(true);
setError(null);
try {
const response = await fetch(`/api/tasks/${task.id}/issue`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Issue konnte nicht angelegt werden.");
onClose();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setIssueBusy(false);
}
}
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setTitle(task?.title ?? ""); setTitle(task?.title ?? "");
@@ -231,6 +280,54 @@ export function TaskDialog({
</p> </p>
) : null} ) : null}
{/* Verknuepfung mit einem Git-Issue. Nur beim Bearbeiten – zu einer
Aufgabe, die es noch nicht gibt, laesst sich keins anlegen. */}
{isEdit ? (
<div className="rounded-lg border border-white/8 bg-white/2 px-3 py-2.5">
{task?.sourceUrl || task?.sourceIssue ? (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-400">
<GitPullRequestArrow className="h-3.5 w-3.5 shrink-0 text-violet-300" />
Verknuepft mit
{task.sourceUrl ? (
<a
href={task.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-violet-300 underline-offset-2 hover:underline"
>
Issue #{task.sourceIssue}
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-slate-300">Issue #{task.sourceIssue}</span>
)}
<span className="w-full text-[11px] text-slate-500">
Erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder.
</span>
</p>
) : (
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-slate-500">
Noch nicht mit einem Issue verknuepft.
</span>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={createIssue}
disabled={busy || issueBusy}
>
{issueBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<GitPullRequestArrow className="h-3.5 w-3.5" />
)}
Als Issue anlegen
</button>
</div>
)}
</div>
) : null}
<div className="flex items-center justify-between gap-2 pt-1"> <div className="flex items-center justify-between gap-2 pt-1">
{isEdit ? ( {isEdit ? (
<button <button
+81 -8
View File
@@ -10,10 +10,13 @@ import {
Loader2, Loader2,
Plus, Plus,
Repeat, Repeat,
Search,
Square, Square,
X,
} from "lucide-react"; } from "lucide-react";
import type { TaskStatus } from "@prisma/client"; import type { TaskStatus } from "@prisma/client";
import { QuickCapture } from "@/components/QuickCapture"; import { QuickCapture } from "@/components/QuickCapture";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import { import {
DUE_BUCKET_META, DUE_BUCKET_META,
DUE_BUCKET_ORDER, DUE_BUCKET_ORDER,
@@ -41,9 +44,12 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
const router = useRouter(); const router = useRouter();
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]); const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState(""); const [projektFilter, setProjektFilter] = useState("");
const [suche, setSuche] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false); const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false); const [erfassenOffen, setErfassenOffen] = useState(false);
const [laufend, setLaufend] = useState<Set<string>>(new Set()); const [laufend, setLaufend] = useState<Set<string>>(new Set());
/** Welche Aufgabe gerade im Dialog liegt. null heisst: keiner offen. */
const [bearbeiten, setBearbeiten] = useState<TaskOverviewItem | null>(null);
const projekte = useMemo(() => { const projekte = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@@ -60,16 +66,34 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
}, [tasks]); }, [tasks]);
const sichtbar = useMemo(() => { const sichtbar = useMemo(() => {
const nadel = suche.toLowerCase().trim();
return tasks.filter((task) => { return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht. // Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
if (!zeigeErledigte && task.status === "DONE" && !statusFilter.includes("DONE")) { // Wer allerdings sucht, sucht auch in Erledigtem – sonst findet man
// die Aufgabe von letzter Woche nicht wieder.
if (
!zeigeErledigte &&
!nadel &&
task.status === "DONE" &&
!statusFilter.includes("DONE")
) {
return false; return false;
} }
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false; if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
if (projektFilter && task.project.id !== projektFilter) return false; if (projektFilter && task.project.id !== projektFilter) return false;
return true; if (!nadel) return true;
// Die Aufgaben liegen hier ohnehin alle vor – das filtert ohne
// zusaetzliche Abfrage und ohne Verzoegerung beim Tippen.
return (
task.title.toLowerCase().includes(nadel) ||
(task.description ?? "").toLowerCase().includes(nadel) ||
task.labels.some((label) => label.toLowerCase().includes(nadel)) ||
task.project.name.toLowerCase().includes(nadel)
);
}); });
}, [tasks, statusFilter, projektFilter, zeigeErledigte]); }, [tasks, statusFilter, projektFilter, zeigeErledigte, suche]);
const faecher = useMemo(() => { const faecher = useMemo(() => {
return DUE_BUCKET_ORDER.map((bucket) => ({ return DUE_BUCKET_ORDER.map((bucket) => ({
@@ -152,6 +176,27 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
{/* Steuerleiste */} {/* Steuerleiste */}
<div className="glass flex flex-col gap-3 p-3 sm:p-4"> <div className="glass flex flex-col gap-3 p-3 sm:p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
placeholder="Aufgaben durchsuchen – Titel, Beschreibung, Labels…"
value={suche}
onChange={(event) => setSuche(event.target.value)}
aria-label="Aufgaben durchsuchen"
/>
{suche ? (
<button
type="button"
onClick={() => setSuche("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
aria-label="Suche zuruecksetzen"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{TASK_STATUS_ORDER.map((status) => { {TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status]; const meta = TASK_STATUS_META[status];
@@ -217,12 +262,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
</span> </span>
<div> <div>
<h2 className="text-lg font-semibold text-white"> <h2 className="text-lg font-semibold text-white">
{tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"} {tasks.length === 0
? "Noch keine Aufgaben"
: suche.trim()
? "Keine Treffer"
: "Nichts offen"}
</h2> </h2>
<p className="mt-1 max-w-md text-sm text-slate-400"> <p className="mt-1 max-w-md text-sm text-slate-400">
{tasks.length === 0 {tasks.length === 0
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“." ? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
: "Alles abgehakt, was zu den Filtern passt."} : suche.trim()
? `Keine Aufgabe passt zu „${suche.trim()}“ – gesucht wird in Titel, Beschreibung, Labels und Projektname.`
: "Alles abgehakt, was zu den Filtern passt."}
</p> </p>
</div> </div>
</div> </div>
@@ -253,6 +304,7 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
task={task} task={task}
busy={laufend.has(task.id)} busy={laufend.has(task.id)}
onToggle={() => abhaken(task)} onToggle={() => abhaken(task)}
onEdit={() => setBearbeiten(task)}
/> />
))} ))}
</ul> </ul>
@@ -267,6 +319,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
onClose={() => setErfassenOffen(false)} onClose={() => setErfassenOffen(false)}
initialMode="aufgabe" initialMode="aufgabe"
/> />
{/* Derselbe Dialog wie im Projektbrett – er kennt seine Aufgabe ueber
die ID und braucht das Projekt nur zum Anlegen. */}
{bearbeiten ? (
<TaskDialog
open
projectId={bearbeiten.project.id}
task={bearbeiten}
initialStatus={bearbeiten.status}
onClose={() => setBearbeiten(null)}
/>
) : null}
</div> </div>
); );
} }
@@ -275,10 +339,12 @@ function TaskRow({
task, task,
busy, busy,
onToggle, onToggle,
onEdit,
}: { }: {
task: TaskOverviewItem; task: TaskOverviewItem;
busy: boolean; busy: boolean;
onToggle: () => void; onToggle: () => void;
onEdit: () => void;
}) { }) {
const meta = TASK_STATUS_META[task.status]; const meta = TASK_STATUS_META[task.status];
const erledigt = task.status === "DONE"; const erledigt = task.status === "DONE";
@@ -303,14 +369,21 @@ function TaskRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span {/* Der Titel oeffnet den Bearbeiten-Dialog. Frueher fuehrte von
hier nur der Weg ins Projekt – wer eine Faelligkeit aendern
wollte, musste erst dorthin springen und die Aufgabe im Brett
wiederfinden. */}
<button
type="button"
onClick={onEdit}
className={cn( className={cn(
"truncate text-sm", "truncate text-left text-sm transition-colors hover:text-violet-200",
erledigt ? "text-slate-500 line-through" : "text-slate-100", erledigt ? "text-slate-500 line-through" : "text-slate-100",
)} )}
title="Aufgabe bearbeiten"
> >
{task.title} {task.title}
</span> </button>
{task.recurrence ? ( {task.recurrence ? (
<Repeat <Repeat
className="h-3 w-3 shrink-0 text-slate-500" className="h-3 w-3 shrink-0 text-slate-500"
+44 -1
View File
@@ -33,9 +33,45 @@ export function Modal({
width = "lg", width = "lg",
}: ModalProps) { }: ModalProps) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [sichtfenster, setSichtfenster] = useState<{ height: number; top: number } | null>(null);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
/**
* Der Dialog richtet sich nach dem *sichtbaren* Fenster, nicht nach dem
* Layoutfenster.
*
* Auf dem Telefon schiebt sich die Tastatur ueber die untere Haelfte,
* ohne dass sich daran etwas fuer die Seite aendert: "position: fixed"
* und "100vh" beziehen sich weiter auf das volle Fenster. Ein zentrierter
* Dialog liegt damit zur Haelfte unter der Tastatur – genau dort, wo das
* Eingabefeld ist.
*
* Die VisualViewport-API meldet, was wirklich zu sehen ist. Hoehe und
* oberer Rand daraus zu setzen rueckt den Dialog in den freien Bereich,
* und weil der Behaelter scrollbar ist, holt der Browser das fokussierte
* Feld von selbst ins Bild.
*/
useEffect(() => {
if (!open) return;
const fenster = window.visualViewport;
if (!fenster) return;
const nachfuehren = () => {
setSichtfenster({ height: fenster.height, top: fenster.offsetTop });
};
nachfuehren();
fenster.addEventListener("resize", nachfuehren);
// Beim Scrollen mit offener Tastatur wandert der sichtbare Ausschnitt.
fenster.addEventListener("scroll", nachfuehren);
return () => {
fenster.removeEventListener("resize", nachfuehren);
fenster.removeEventListener("scroll", nachfuehren);
setSichtfenster(null);
};
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@@ -53,7 +89,14 @@ export function Modal({
if (!mounted || !open) return null; if (!mounted || !open) return null;
return createPortal( return createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8"> <div
className="fixed inset-x-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8"
style={
sichtfenster
? { top: sichtfenster.top, height: sichtfenster.height }
: { top: 0, bottom: 0 }
}
>
<button <button
type="button" type="button"
aria-label="Dialog schliessen" aria-label="Dialog schliessen"
+2 -2
View File
@@ -46,7 +46,7 @@ export function setMfaCookie(response: NextResponse, token: string) {
name: MFA_COOKIE, name: MFA_COOKIE,
value: token, value: token,
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: Math.floor(MFA_TTL_MS / 1000), maxAge: Math.floor(MFA_TTL_MS / 1000),
@@ -58,7 +58,7 @@ export function clearMfaCookie(response: NextResponse) {
name: MFA_COOKIE, name: MFA_COOKIE,
value: "", value: "",
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: 0, maxAge: 0,
+80 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants"; import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password"; import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => { describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => { it("erkennt das richtige Passwort wieder", async () => {
@@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/); expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
}); });
}); });
describe("Aufwand nach OWASP", () => {
it("hasht mit den heute vorgesehenen Parametern", async () => {
// OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1.
const hash = await hashPassword("ein-langes-testpasswort");
const [, N, r, p] = hash.split("$");
expect(Number(N)).toBe(131072);
expect(Number(r)).toBe(8);
expect(Number(p)).toBe(1);
});
it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => {
// Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben,
// sonst waere ein Anheben des Aufwands eine Aussperrung.
const alt =
"scrypt$32768$8$1$" +
Buffer.from("salzsalzsalzsalz").toString("base64url") +
"$";
// Echten Hash mit alten Parametern bauen:
const { scrypt } = await import("node:crypto");
const { promisify } = await import("node:util");
const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise<Buffer>;
const salt = Buffer.from("salzsalzsalzsalz");
const derived = await s("ein-langes-testpasswort", salt, 64, {
N: 32768,
r: 8,
p: 1,
maxmem: 96 * 1024 * 1024,
});
const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`;
expect(alt.length).toBeGreaterThan(0);
await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true);
// ... und wird als erneuerungsbeduerftig erkannt.
expect(needsRehash(alterHash)).toBe(true);
});
it("laesst frische Hashes in Ruhe", async () => {
expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false);
});
it("erkennt gleichwertige Kombinationen als ausreichend", () => {
// N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und
// ist nach OWASP ebenso zulaessig.
expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false);
// N=2^15 mit p=1 dagegen nicht – das war der alte Zustand.
expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true);
});
it("haelt Unsinn fuer erneuerungsbeduerftig", () => {
expect(needsRehash("voelliger-unsinn")).toBe(true);
expect(needsRehash(null)).toBe(false);
});
});
describe("Schwache Muster", () => {
it("weist Wiederholungen ab", () => {
expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy();
expect(validatePasswordStrength("abababababab")).toBeTruthy();
});
it("weist Zeichenfolgen ab", () => {
expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy();
expect(validatePasswordStrength("87654321abcd")).toBeTruthy();
});
it("weist Passwoerter mit dem Benutzernamen ab", () => {
expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/);
// Ohne Benutzernamen ist dasselbe Passwort in Ordnung.
expect(validatePasswordStrength("chefin-geheimnis")).toBeNull();
// Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles.
expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull();
});
it("laesst ein gutes Passwort mit Wiederholung durch", () => {
// Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist.
expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull();
});
});
+122 -10
View File
@@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as (
options: { N: number; r: number; p: number; maxmem: number }, options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>; ) => Promise<Buffer>;
const PARAMS = { N: 32768, r: 8, p: 1 }; /**
* Arbeitsaufwand nach der Empfehlung des OWASP Password Storage Cheat Sheet:
* N = 2^17, r = 8, p = 1. Frueher stand hier N = 2^15 mit p = 1 – das liegt
* unter *jeder* der dort genannten zulaessigen Kombinationen (zu 2^15
* gehoerte p = 3) und war damit rund dreimal billiger zu knacken als
* vorgesehen.
*
* Gemessen kostet das rund 0,5 s je Pruefung und 128 MiB. Der Speicher
* klingt nach viel, ist aber gedeckelt: Node rechnet scrypt im
* libuv-Threadpool, standardmaessig vier Faeden parallel. Mehr als rund
* 512 MiB koennen also nicht gleichzeitig belegt sein, und davor liegen
* noch die Ratenbegrenzungen je IP und je Benutzername.
*/
const PARAMS = { N: 131072, r: 8, p: 1 };
const KEY_LENGTH = 64; const KEY_LENGTH = 64;
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht // scrypt braucht rund 128 * N * r Bytes – bei diesen Werten 128 MiB.
// dafuer nicht ganz. // Etwas Luft nach oben, damit die Pruefung nicht am Deckel scheitert.
const MAX_MEM = 96 * 1024 * 1024; const MAX_MEM = 192 * 1024 * 1024;
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16); const salt = randomBytes(16);
@@ -72,6 +85,30 @@ export async function verifyPassword(
return timingSafeEqual(derived, expected); return timingSafeEqual(derived, expected);
} }
/**
* Ist der gespeicherte Hash mit schwaecheren Parametern erzeugt worden als
* heute ueblich?
*
* Der Aufwand laesst sich nur fuer neue Passwoerter anheben – bestehende
* Hashes bleiben sonst fuer immer auf dem alten Stand. OWASP empfiehlt
* deshalb, bei der naechsten erfolgreichen Anmeldung still neu zu hashen.
* Das geht nur dort, weil das Klartextpasswort ausschliesslich in diesem
* Moment vorliegt.
*/
export function needsRehash(stored: string | null | undefined): boolean {
if (!stored) return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
const [N, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return true;
// Verglichen wird der Gesamtaufwand, nicht Feld fuer Feld: N=2^16 mit p=2
// ist nach OWASP genauso zulaessig wie N=2^17 mit p=1.
return N * r * p < PARAMS.N * PARAMS.r * PARAMS.p;
}
/** /**
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen, * Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der * wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
@@ -84,27 +121,102 @@ export async function fakeVerifyDelay(): Promise<void> {
}); });
} }
/**
* Naheliegende Passwoerter.
*
* ASVS 2.1.7 verlangt einen Abgleich gegen bekannte geleakte Passwoerter.
* Ein Dienst wie Have-I-Been-Pwned schiede dafuer aus – Nebula ruft
* grundsaetzlich nichts von aussen, und ein Passwort-Praefix nach draussen
* zu geben widerspraeche dem erst recht. Diese Liste ist der Kompromiss:
* die haeufigsten deutschen und englischen Kandidaten, dazu weiter unten
* Muster statt Einzelwoerter, was mehr abdeckt als jede Liste dieser Groesse.
*/
const OBVIOUS = new Set([ const OBVIOUS = new Set([
"password", "passwort", "12345678", "123456789", "1234567890", // Klassiker
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein", "password", "password1", "password123", "passwort", "passwort1",
"willkommen", "nebula123", "geheim123", "passwort123", "kennwort", "geheim", "geheim123", "changeme", "letmein",
"welcome", "willkommen", "willkommen1", "administrator", "admin1234",
"iloveyou", "sunshine", "princess", "dragon", "monkey", "football",
"baseball", "superman", "trustno1", "starwars", "whatever", "freedom",
// Zahlen und Tastaturlaeufe
"12345678", "123456789", "1234567890", "87654321", "11111111",
"qwertzuiop", "qwertyuiop", "asdfghjkl", "qwertz123", "qwerty123",
"1qay2wsx", "1q2w3e4r", "1q2w3e4r5t", "asdf1234", "qwer1234",
// Deutsch
"sommer2024", "sommer2025", "sommer2026", "winter2024", "winter2025",
"winter2026", "fussball", "deutschland", "hallowelt", "sonnenschein",
"schatz123", "liebling", "blumen123", "internet", "computer",
// Projektnah
"nebula", "nebula123", "nebula2026", "dashboard", "vibecoding",
]); ]);
export { PASSWORD_MIN_LENGTH }; export { PASSWORD_MIN_LENGTH };
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */ /**
export function validatePasswordStrength(password: string): string | null { * Laenge der laengsten auf- oder absteigenden Zeichenfolge ("abcd", "4321").
*
* Zurueckgegeben wird die Laenge und nicht nur ein Ja/Nein: entscheidend ist,
* *wie viel* des Passworts aus der Folge besteht. Ein "de" mitten in einem
* Wort ist harmlos, "abcdefghijkl" ist das ganze Passwort.
*/
function laengsteFolge(wert: string): number {
if (wert.length < 2) return wert.length;
let beste = 1;
let auf = 1;
let ab = 1;
for (let i = 1; i < wert.length; i += 1) {
const schritt = wert.charCodeAt(i) - wert.charCodeAt(i - 1);
auf = schritt === 1 ? auf + 1 : 1;
ab = schritt === -1 ? ab + 1 : 1;
beste = Math.max(beste, auf, ab);
}
return beste;
}
/**
* Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt.
*
* Bewusst **keine** Zusammensetzungsregeln ("mindestens ein Sonderzeichen").
* ASVS 2.1.9 raet ausdruecklich davon ab: sie treiben Leute zu "Passwort1!"
* und senken die Entropie, statt sie zu heben. Geprueft wird stattdessen
* auf Laenge und auf das, was nachweislich schwach ist.
*/
export function validatePasswordStrength(
password: string,
/** Benutzername des Kontos, falls bekannt – er darf nicht im Passwort stehen. */
username?: string | null,
): string | null {
if (password.length < PASSWORD_MIN_LENGTH) { if (password.length < PASSWORD_MIN_LENGTH) {
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`; return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
} }
if (password.length > 200) { if (password.length > 200) {
return "Das Passwort darf hoechstens 200 Zeichen haben."; return "Das Passwort darf hoechstens 200 Zeichen haben.";
} }
if (OBVIOUS.has(password.toLowerCase())) {
const klein = password.toLowerCase();
if (OBVIOUS.has(klein)) {
return "Dieses Passwort ist zu naheliegend."; return "Dieses Passwort ist zu naheliegend.";
} }
if (/^\d+$/.test(password)) { if (/^\d+$/.test(password)) {
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen."; return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
} }
// Zu wenig verschiedene Zeichen – deckt "aaaaaaaa" ebenso ab wie
// "abababab" oder "12121212", ohne dafuer je ein eigenes Muster zu
// brauchen.
if (new Set(klein).size <= 4) {
return "Das Passwort benutzt zu wenige verschiedene Zeichen.";
}
// Und wenn der groesste Teil des Passworts eine durchlaufende Folge ist.
if (laengsteFolge(klein) >= Math.max(6, Math.ceil(password.length * 0.6))) {
return "Zeichenfolgen wie „abcd“ oder „4321“ sind zu leicht zu raten.";
}
// Ein Passwort, das den Benutzernamen enthaelt, ist der erste Versuch
// jedes Angreifers.
const name = username?.trim().toLowerCase();
if (name && name.length >= 3 && klein.includes(name)) {
return "Das Passwort darf den Benutzernamen nicht enthalten.";
}
return null; return null;
} }
+33 -6
View File
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { NextResponse } from "next/server"; import type { NextResponse } from "next/server";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config"; import {
COOKIES_SECURE,
SESSION_COOKIE,
SESSION_IDLE_MS,
SESSION_TTL_MS,
} from "@/lib/config";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
/** /**
@@ -68,7 +73,10 @@ export function setSessionCookie(response: NextResponse, token: string, expiresA
name: SESSION_COOKIE, name: SESSION_COOKIE,
value: token, value: token,
httpOnly: true, httpOnly: true,
sameSite: "lax", // Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
expires: expiresAt, expires: expiresAt,
@@ -80,7 +88,10 @@ export function clearSessionCookie(response: NextResponse) {
name: SESSION_COOKIE, name: SESSION_COOKIE,
value: "", value: "",
httpOnly: true, httpOnly: true,
sameSite: "lax", // Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE, secure: COOKIES_SECURE,
path: "/", path: "/",
maxAge: 0, maxAge: 0,
@@ -109,11 +120,27 @@ export async function getSessionUser(): Promise<SessionUser | null> {
return null; return null;
} }
// Leerlaufgrenze. Vorher wurde lastSeenAt zwar gefuehrt, aber nie
// ausgewertet – ein abgegriffener Cookie galt damit bis zum absoluten
// Ablauf, auch wenn das Konto wochenlang unberuehrt war.
const untaetig = Date.now() - session.lastSeenAt.getTime();
if (SESSION_IDLE_MS > 0 && untaetig > SESSION_IDLE_MS) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null; if (!session.user.isActive) return null;
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die /**
// Datenbank anfasst. * lastSeenAt nachfuehren, aber nicht bei jeder Anfrage schreiben.
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) { *
* Das Intervall muss deutlich unter der Leerlaufgrenze liegen, sonst
* verkuerzt die Schreibbremse sie: bei stuendlichem Schreiben und einer
* Grenze von einer Stunde waere die Sitzung schon nach Sekunden faellig.
* Ein Zwanzigstel der Frist, hoechstens fuenf Minuten.
*/
const intervall = SESSION_IDLE_MS > 0 ? Math.min(SESSION_IDLE_MS / 20, 5 * 60 * 1000) : 60 * 60 * 1000;
if (untaetig > intervall) {
void prisma.session void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } }) .update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined); .catch(() => undefined);
+160
View File
@@ -41,6 +41,166 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.10.1",
date: "2026-08-18",
headline: "Sichtbar, welche Aufgabe ein Issue hat — und nachlesbar, was ein Token dafür braucht.",
changes: [
{
kind: "neu",
text:
"Auf dem Aufgabenbrett eines Projekts trägt eine Karte die Nummer ihres " +
"verknüpften Git-Issues. Bisher stand die Verknüpfung nur im Bearbeiten-Dialog " +
"und in der projektübergreifenden Übersicht — welche Aufgabe schon ein Issue " +
"hat, war im Vorbeigehen nicht zu sehen.",
},
{
kind: "besser",
text:
"Die Rechte einer Git-Verbindung stehen jetzt Aufruf für Aufruf in der README: " +
"je System eine Tabelle aus Endpunkt, Zweck und benötigtem Recht. Getrennt nach " +
"reinem Abgleich und zusätzlicher Issue-Anbindung, damit erkennbar ist, was sich " +
"weglassen lässt. Bei GitLab ist belegt, dass es unterhalb von „api“ keinen " +
"Schreibzugriff auf Issues gibt.",
},
{
kind: "behoben",
text:
"Der Hinweis unter dem Token-Feld nannte die Rechte für Actions nicht, obwohl " +
"der CI-Zustand bei GitHub genau daran hängt. Wer sich daran hielt, sah keinen " +
"Build-Status.",
},
],
},
{
version: "1.10.0",
date: "2026-08-18",
headline: "Rückblick, Zeitleiste und eine Suche, die auch Aufgaben findet.",
changes: [
{
kind: "neu",
text:
"Wochenrückblick über alle Projekte: Kennzahlen der Woche und darunter die " +
"Vorgänge nach Tagen gebündelt. Blättern in frühere Wochen inbegriffen. " +
"Erreichbar über die Schnellsuche und einen Verweis vom Dashboard.",
},
{
kind: "neu",
text:
"Globale Zeitleiste: derselbe Verlauf, aber fortlaufend und ohne Wochengrenze, " +
"mit Nachladen älterer Einträge. Geblättert wird per Cursor — bei mehreren " +
"Einträgen im selben Augenblick verliert oder verdoppelt das keine Zeilen.",
},
{
kind: "neu",
text:
"Das Aufgabendashboard hat ein Suchfeld über Titel, Beschreibung, Labels und " +
"Projektname. Wer sucht, sucht dabei auch in Erledigtem — sonst findet man die " +
"Aufgabe von letzter Woche nicht wieder.",
},
{
kind: "besser",
text:
"Die Volltextsuche kannte bisher nur Notizen und findet jetzt auch Aufgaben — " +
"mit denselben deutschen Stammformen: „Rechnung“ findet „Rechnungen“. Treffer " +
"erscheinen im Projektbrett und in der Schnellsuche.",
},
],
},
{
version: "1.9.0",
date: "2026-08-17",
headline: "Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab.",
changes: [
{
kind: "neu",
text:
"Der Abgleich prüft jetzt den Zustand verknüpfter Issues: was in Git geschlossen " +
"wurde, wird in Nebula abgehakt. Bisher konnte das nur der Webhook — war keiner " +
"eingerichtet, blieb der Rückweg ganz aus.",
},
{
kind: "besser",
text:
"Ein über Git geschlossenes Issue bewirkt jetzt dasselbe wie ein Haken in der " +
"Oberfläche: bei wiederkehrenden Aufgaben entsteht die nächste Fassung, und der " +
"Vorgang steht im Projektverlauf.",
},
],
},
{
version: "1.8.0",
date: "2026-08-15",
headline: "Durchgang durch Anmeldung und Sitzungen nach OWASP.",
changes: [
{
kind: "besser",
text:
"Passwörter werden deutlich aufwendiger gehasht (scrypt mit N=2^17 statt 2^15) — " +
"das entspricht der Empfehlung des OWASP Password Storage Cheat Sheet. Bestehende " +
"Passwörter werden bei der nächsten Anmeldung still auf den neuen Stand gebracht.",
},
{
kind: "behoben",
text:
"Die Anmeldung verriet, welche Benutzernamen existieren: gesperrte, deaktivierte " +
"und reine Passkey-Konten antworteten anders als unbekannte — und das schon vor " +
"der Passwortprüfung. Jetzt gibt es die Auskunft erst, wenn das Passwort stimmt.",
},
{
kind: "neu",
text:
"Sitzungen laufen nach acht Stunden ohne Nutzung ab, zusätzlich zur bestehenden " +
"Frist von 30 Tagen. Einstellbar über SESSION_IDLE_HOURS; 0 schaltet es ab.",
},
{
kind: "besser",
text:
"Das Sitzungscookie trägt bei HTTPS den Präfix „__Host-“ und gilt nur noch " +
"SameSite=Strict. Beides muss einmalig neu angemeldet werden.",
},
{
kind: "besser",
text:
"Die Passwortprüfung erkennt jetzt Wiederholungen, Zeichenfolgen und Passwörter, " +
"die den eigenen Benutzernamen enthalten.",
},
],
},
{
version: "1.7.0",
date: "2026-08-15",
headline: "Aufgaben und Git-Issues finden zusammen.",
changes: [
{
kind: "neu",
text:
"Eine Aufgabe lässt sich als Issue im verknüpften Repository anlegen — per Knopf, " +
"nie nebenbei. Danach wandert der Zustand mit: erledigt schließt das Issue, ein " +
"Zurückziehen öffnet es wieder.",
},
{
kind: "neu",
text:
"Webhooks. Ein Push aktualisiert den Stand sofort, statt bis zum nächsten " +
"Abgleich zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Einzurichten " +
"am Repository — URL und Geheimnis stehen dort bereit.",
},
{
kind: "behoben",
text:
"Auf dem Telefon verdeckte die Bildschirmtastatur den halben Dialog, weil er mit " +
"der vollen Fensterhöhe rechnete. Dialoge richten sich jetzt nach dem sichtbaren " +
"Bereich und rücken über die Tastatur.",
},
{
kind: "besser",
text:
"Aufgaben lassen sich in der Übersicht direkt bearbeiten. Bisher führte von dort " +
"nur der Weg ins Projekt.",
},
],
},
{ {
version: "1.6.0", version: "1.6.0",
date: "2026-08-14", date: "2026-08-14",
+32 -3
View File
@@ -73,18 +73,47 @@ export const WEBAUTHN_AVAILABLE =
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */ /** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:"; export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
/** Lebensdauer einer Sitzung. */ /**
* Absolute Lebensdauer einer Sitzung – ab Anmeldung, nicht verlaengerbar.
* Nach dieser Frist ist Schluss, egal wie aktiv jemand war.
*/
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30); export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session"; /**
* Leerlauffrist: so lange darf eine Sitzung unbenutzt bleiben.
*
* ASVS 3.3.2 verlangt beides – eine absolute *und* eine Leerlaufgrenze.
* Vorher gab es nur die absolute, ein abgegriffener Cookie galt also bis zu
* 30 Tage, unabhaengig davon, ob das Konto benutzt wurde.
*
* Acht Stunden sind der Kompromiss: ein Arbeitstag am Stueck bleibt
* angemeldet, ueber Nacht laeuft die Sitzung ab. Wem das zu streng ist,
* setzt SESSION_IDLE_HOURS hoch; 0 schaltet die Leerlaufgrenze ab.
*/
export const SESSION_IDLE_HOURS = Number(process.env.SESSION_IDLE_HOURS ?? 8);
export const SESSION_IDLE_MS =
SESSION_IDLE_HOURS > 0 ? SESSION_IDLE_HOURS * 60 * 60 * 1000 : 0;
/**
* Name des Sitzungscookies.
*
* Mit dem Praefix "__Host-" erzwingt der Browser selbst, was wir ohnehin
* setzen: Secure, Pfad "/" und kein Domain-Attribut. Letzteres ist der
* eigentliche Gewinn – eine andere Subdomain kann den Cookie dann nicht
* ueberschreiben und damit keine Sitzung unterschieben.
*
* Nur bei https, denn der Praefix verlangt zwingend Secure; ohne TLS wuerde
* der Browser den Cookie schlicht verwerfen.
*/
export const SESSION_COOKIE = COOKIES_SECURE ? "__Host-nebula_session" : "nebula_session";
/** /**
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor. * Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von * Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
* vorne an. * vorne an.
*/ */
export const MFA_COOKIE = "nebula_mfa"; export const MFA_COOKIE = COOKIES_SECURE ? "__Host-nebula_mfa" : "nebula_mfa";
export const MFA_TTL_MS = 5 * 60 * 1000; 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. */
+37 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import { import {
CALENDAR_PAGE_SIZE, CALENDAR_PAGE_SIZE,
calendarByPaging, calendarByPaging,
@@ -12,6 +12,7 @@ import type {
CiState, CiState,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
CreatedIssue,
GitClient, GitClient,
ProviderConfig, ProviderConfig,
ReleaseInfo, ReleaseInfo,
@@ -44,6 +45,41 @@ export class GiteaClient implements GitClient {
return headers; return headers;
} }
private repoBase(ref: RepoRef): string {
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
}
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
const angelegt = await sendJson<{ number: number; html_url?: string }>(
`${this.repoBase(ref)}/issues`,
"POST",
{ title: issue.title, body: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.number, url: angelegt.html_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.repoBase(ref)}/issues/${number}`,
"PATCH",
{ state: open ? "open" : "closed" },
{ headers: this.headers() },
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
const version = await getJsonSafe<{ version?: string }>( const version = await getJsonSafe<{ version?: string }>(
+37 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -6,6 +6,7 @@ import type {
CommitCalendar, CommitCalendar,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
CreatedIssue,
GitClient, GitClient,
ProviderConfig, ProviderConfig,
ReleaseInfo, ReleaseInfo,
@@ -43,6 +44,41 @@ export class GitHubClient implements GitClient {
return headers; return headers;
} }
private repoBase(ref: RepoRef): string {
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
}
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
const angelegt = await sendJson<{ number: number; html_url?: string }>(
`${this.repoBase(ref)}/issues`,
"POST",
{ title: issue.title, body: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.number, url: angelegt.html_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.repoBase(ref)}/issues/${number}`,
"PATCH",
{ state: open ? "open" : "closed" },
{ headers: this.headers() },
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
+36 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http"; import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import { import {
CALENDAR_PAGE_SIZE, CALENDAR_PAGE_SIZE,
calendarByPaging, calendarByPaging,
@@ -12,6 +12,7 @@ import type {
CiState, CiState,
CommitInfo, CommitInfo,
ConnectionCheck, ConnectionCheck,
CreatedIssue,
GitClient, GitClient,
ProviderConfig, ProviderConfig,
ReleaseInfo, ReleaseInfo,
@@ -45,6 +46,40 @@ export class GitLabClient implements GitClient {
return headers; return headers;
} }
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
// GitLab nennt es "iid" – die projektinterne Nummer, die auch in der
// Oberflaeche steht. "id" waere die instanzweite und damit die falsche.
const angelegt = await sendJson<{ iid: number; web_url?: string }>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues`,
"POST",
{ title: issue.title, description: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.iid, url: angelegt.web_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
"PUT",
{ state_event: open ? "reopen" : "close" },
{ headers: this.headers() },
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
// GitLab nennt den offenen Zustand "opened".
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
+77
View File
@@ -98,6 +98,83 @@ export async function getJson<T>(
} }
} }
/**
* Schreibender Aufruf – POST, PATCH oder PUT.
*
* Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem
* Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus
* unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt.
* Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der
* enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine
* Anfrage, die woanders landet.
*/
export async function sendJson<T>(
url: string,
method: "POST" | "PATCH" | "PUT",
body: unknown,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
await assertAllowedUrl(url);
const response = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
cache: "no-store",
redirect: "manual",
});
// Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass
// fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall.
if (response.status >= 300 && response.status < 400) {
throw new GitApiError(
"Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " +
"bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.",
response.status,
url,
);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */ /** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
export async function getJsonSafe<T>( export async function getJsonSafe<T>(
url: string, url: string,
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_META, guessProvider } from "./meta";
const PROVIDER = ["GITHUB", "GITLAB", "GITEA"] as const;
describe("PROVIDER_META", () => {
it("kennt jedes unterstuetzte System", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider], provider).toBeDefined();
}
// Und nichts darueber hinaus – ein Eintrag ohne Gegenstueck im Schema
// waere ein Ueberbleibsel.
expect(Object.keys(PROVIDER_META).sort()).toEqual([...PROVIDER].sort());
});
it("hat zu jedem System Beschriftung, Basis-URL und Dokuverweis", () => {
for (const provider of PROVIDER) {
const meta = PROVIDER_META[provider];
expect(meta.label.length, provider).toBeGreaterThan(0);
expect(meta.defaultBaseUrl, provider).toMatch(/^https:\/\//);
expect(meta.docsUrl, provider).toMatch(/^https:\/\//);
}
});
/**
* Der eigentliche Waechter.
*
* Der Hinweis am Eingabefeld war eine Zeit lang rein lesend formuliert,
* obwohl Nebula seit v1.7.0 Issues anlegt und schliesst – wer sich daran
* hielt, bekam einen 403. Jeder Provider in GitClient muss createIssue und
* setIssueState koennen; folglich muss auch jeder Hinweis sagen, was das
* an Rechten kostet.
*/
it("sagt bei jedem System etwas ueber die Rechte fuer Issues", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider].tokenHelp, provider).toMatch(/issue/i);
}
});
it("nennt einen konkreten Scope statt nur 'Leserechte'", () => {
// GitLab nennt Scopes in Anfuehrungszeichen, GitHub und Gitea
// Berechtigungsnamen – gemeinsam ist, dass etwas Nachschlagbares dasteht.
expect(PROVIDER_META.GITLAB.tokenHelp).toMatch(/read_api/);
expect(PROVIDER_META.GITEA.tokenHelp).toMatch(/read:repository/);
expect(PROVIDER_META.GITHUB.tokenHelp).toMatch(/Metadata/);
});
});
describe("guessProvider", () => {
it("erkennt die oeffentlichen Instanzen am Hostnamen", () => {
expect(guessProvider("github.com")).toBe("GITHUB");
expect(guessProvider("gitlab.com")).toBe("GITLAB");
});
it("faellt bei allem anderen auf Gitea zurueck", () => {
// Selbst gehostet ist hier der haeufigste Fall, und Gitea die
// wahrscheinlichste Wahl dafuer.
expect(guessProvider("git.wilde-server.de")).toBe("GITEA");
});
});
+9 -3
View File
@@ -12,19 +12,25 @@ export const PROVIDER_META: Record<
label: "GitHub", label: "GitHub",
defaultBaseUrl: "https://github.com", defaultBaseUrl: "https://github.com",
tokenHelp: tokenHelp:
"Fine-grained Token mit Lesezugriff auf Contents, Metadata, Issues und Pull Requests.", "Fine-grained Token, alle nur lesend: Metadata, Contents, Issues, Pull requests " +
"und Actions. Nur wenn Aufgaben als Issue angelegt werden sollen, Issues " +
"zusaetzlich schreiben.",
docsUrl: "https://github.com/settings/tokens", docsUrl: "https://github.com/settings/tokens",
}, },
GITLAB: { GITLAB: {
label: "GitLab", label: "GitLab",
defaultBaseUrl: "https://gitlab.com", defaultBaseUrl: "https://gitlab.com",
tokenHelp: "Personal Access Token mit Scope 'read_api' (bzw. 'read_repository').", tokenHelp:
"Scope 'read_api' genuegt fuer den Abgleich. Aufgaben als Issue anzulegen verlangt " +
"'api' – GitLab kennt keinen schmaleren Schreibzugriff.",
docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens", docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
}, },
GITEA: { GITEA: {
label: "Gitea / Forgejo", label: "Gitea / Forgejo",
defaultBaseUrl: "https://git.example.com", defaultBaseUrl: "https://git.example.com",
tokenHelp: "Access Token mit Leserechten auf repository, issue und user.", tokenHelp:
"read:repository, read:issue und read:user. Nur wenn Aufgaben als Issue angelegt " +
"werden sollen, zusaetzlich write:issue.",
docsUrl: "https://docs.gitea.com/development/api-usage", docsUrl: "https://docs.gitea.com/development/api-usage",
}, },
}; };
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Waechter ueber eine Zusage in der Dokumentation.
*
* README und FEATURES sagen zu, dass Nebula an genau zwei Stellen nach
* draussen schreibt: ein Issue anlegen und seinen Zustand umsetzen. Auf
* dieser Zusage steht die Empfehlung, ein rein lesendes Token zu vergeben,
* solange die Issue-Anbindung nicht gebraucht wird.
*
* Kaeme ein dritter Schreibaufruf dazu, wuerde die Empfehlung still falsch:
* niemand bekaeme einen Fehler, nur ein 403 an einer Stelle, die laut
* Dokumentation gar nicht schreibt. Deshalb wird hier der Quelltext selbst
* abgeklopft – sendJson ist der einzige Weg aus lib/git heraus, der eine
* andere Methode als GET benutzt (siehe http.ts).
*/
const CLIENTS = ["gitea.ts", "github.ts", "gitlab.ts"] as const;
/** Die Zeile ueber dem Methoden-Argument nennt den Endpunkt. */
function schreibaufrufe(quelltext: string): string[] {
const zeilen = quelltext.split("\n");
const gefunden: string[] = [];
zeilen.forEach((zeile, i) => {
if (!/\bsendJson[<(]/.test(zeile)) return;
// sendJson(url, METHODE, …) – Endpunkt und Methode stehen darunter.
const block = zeilen.slice(i, i + 4).join(" ");
const methode = block.match(/"(POST|PATCH|PUT)"/)?.[1];
const pfad = block.match(/\/issues[^`"']*/)?.[0] ?? "?";
gefunden.push(`${methode ?? "?"} ${pfad}`);
});
return gefunden;
}
describe("Schreibzugriffe nach draussen", () => {
it("beschraenken sich auf Issues anlegen und Zustand setzen", () => {
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
const aufrufe = schreibaufrufe(quelltext);
expect(aufrufe.length, `${datei}: Anzahl Schreibaufrufe`).toBe(2);
for (const aufruf of aufrufe) {
expect(aufruf, `${datei}: ${aufruf}`).toMatch(/^(POST|PATCH|PUT) \/issues/);
}
// Genau einer legt an, genau einer setzt um.
expect(aufrufe.filter((a) => a.startsWith("POST")).length, datei).toBe(1);
expect(aufrufe.filter((a) => /^(PATCH|PUT)/.test(a)).length, datei).toBe(1);
}
});
it("laufen ueber sendJson, nicht an http.ts vorbei", () => {
// Ein direkter fetch in einem Client haette weder SSRF-Schutz noch
// Zeitbegrenzung – und wuerde am Waechter oben vorbeilaufen.
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
expect(quelltext, datei).not.toMatch(/\bfetch\s*\(/);
}
});
});
+30
View File
@@ -167,10 +167,40 @@ export interface ProviderConfig {
token: string | null; token: string | null;
} }
/** Ein neu angelegtes Issue, so weit wir es zurueckschreiben. */
export interface CreatedIssue {
number: number;
url: string | null;
}
export interface GitClient { export interface GitClient {
readonly provider: ProviderKind; readonly provider: ProviderKind;
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */ /** Prueft Erreichbarkeit und Token-Gueltigkeit. */
check(): Promise<ConnectionCheck>; check(): Promise<ConnectionCheck>;
/**
* Legt ein Issue an und liefert Nummer und Adresse zurueck.
*
* Der einzige schreibende Aufruf in dieser Schnittstelle. Er wird nie
* nebenbei ausgeloest, sondern immer von Hand angestossen – was nach
* aussen geht, soll jemand gewollt haben.
*/
createIssue(ref: RepoRef, issue: { title: string; body?: string | null }): Promise<CreatedIssue>;
/**
* Schliesst ein Issue. Beim Zurueckziehen einer Aufgabe wird es wieder
* geoeffnet – daher der Schalter statt zweier Methoden.
*/
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
/**
* Liest den Zustand eines einzelnen Issues.
*
* Gebraucht fuer den Abgleich: der Snapshot fuehrt nur die *offenen*
* Issues und ist ausserdem gedeckelt – aus dem Fehlen einer Nummer laesst
* sich deshalb nicht schliessen, dass sie geschlossen wurde. Sie koennte
* ebenso geloescht oder einfach jenseits des Deckels sein.
*
* null heisst: gibt es nicht (mehr).
*/
getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null>;
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */ /** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
listRepos(query?: string): Promise<RepoListItem[]>; listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */ /** Vollstaendiger Stand eines Repositories. */
+50 -1
View File
@@ -65,17 +65,66 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
updatedAt: true, updatedAt: true,
}); });
/**
* Felder eines Repositories, die nach aussen duerfen.
*
* Ausdruecklich als Liste und nicht als "include": ein include liefert jedes
* Skalarfeld mit, auch neu hinzugekommene. Das Webhook-Geheimnis waere so
* still in jede Projektantwort und in jede Seite gewandert – verschluesselt
* zwar, aber nach aussen gehoert es trotzdem nicht. Dieselbe Ueberlegung wie
* bei connectionSafeSelect.
*
* Ob ein Webhook eingerichtet ist, sagt webhookLastAt nicht zuverlaessig
* (frisch eingerichtet ist es null). Diese Angabe holt die Projektseite
* deshalb getrennt – siehe webhookRepositoryIds.
*/
export const repositorySafeSelect = Prisma.validator<Prisma.RepositorySelect>()({
id: true,
projectId: true,
label: true,
repoUrl: true,
repoOwner: true,
repoName: true,
defaultBranch: true,
connectionId: true,
position: true,
lastSyncedAt: true,
syncError: true,
autoSync: true,
syncFailureCount: true,
notifiedSyncError: true,
notifiedCiState: true,
notifiedRelease: true,
webhookLastAt: true,
createdAt: true,
updatedAt: true,
connection: { select: connectionSafeSelect },
snapshot: true,
});
/** /**
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall * Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz. * dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
*/ */
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
repositories: { repositories: {
include: { connection: { select: connectionSafeSelect }, snapshot: true }, select: repositorySafeSelect,
orderBy: [{ position: "asc" }, { createdAt: "asc" }], orderBy: [{ position: "asc" }, { createdAt: "asc" }],
}, },
}); });
/**
* IDs der Repositories eines Projekts, fuer die ein Webhook hinterlegt ist.
* Getrennt geholt, damit das Geheimnis selbst nirgends mitgereicht wird.
*/
export async function webhookRepositoryIds(projectId: string): Promise<string[]> {
const treffer = await prisma.repository.findMany({
where: { projectId, webhookSecret: { not: null } },
select: { id: true },
});
return treffer.map((eintrag) => eintrag.id);
}
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({ export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
...repositoryInclude, ...repositoryInclude,
_count: { select: { notes: true, tasks: true } }, _count: { select: { notes: true, tasks: true } },
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
berlinMitternacht,
tagesschluessel,
wocheUm,
wochenName,
wochenTitel,
zaehleWoche,
} from "./review";
describe("tagesschluessel", () => {
it("rechnet in Berliner Kalendertagen, nicht in UTC", () => {
// 22:30 UTC ist in Berlin schon der Folgetag (Sommerzeit, UTC+2).
expect(tagesschluessel(new Date("2026-08-17T22:30:00Z"))).toBe("2026-08-18");
expect(tagesschluessel(new Date("2026-08-17T21:00:00Z"))).toBe("2026-08-17");
});
});
describe("berlinMitternacht", () => {
it("trifft den Sommerzeit-Beginn des Tages", () => {
// Sommerzeit: Berlin = UTC+2, Mitternacht dort ist 22:00 UTC am Vortag.
expect(berlinMitternacht("2026-08-17").toISOString()).toBe("2026-08-16T22:00:00.000Z");
});
it("trifft den Winterzeit-Beginn des Tages", () => {
// Winterzeit: UTC+1.
expect(berlinMitternacht("2026-01-12").toISOString()).toBe("2026-01-11T23:00:00.000Z");
});
});
describe("wocheUm", () => {
/** Ein Dienstag. */
const DIENSTAG = new Date("2026-08-18T09:00:00Z");
it("beginnt am Montag und endet am folgenden Montag", () => {
const woche = wocheUm(DIENSTAG);
expect(woche.tage[0]).toBe("2026-08-17");
expect(woche.tage[6]).toBe("2026-08-23");
expect(woche.tage).toHaveLength(7);
expect(woche.von.toISOString()).toBe("2026-08-16T22:00:00.000Z");
expect(woche.bis.toISOString()).toBe("2026-08-23T22:00:00.000Z");
});
it("zaehlt den Montag selbst zur laufenden Woche", () => {
const montag = new Date("2026-08-17T06:00:00Z");
expect(wocheUm(montag).tage[0]).toBe("2026-08-17");
});
it("zaehlt den Sonntagabend noch zur ablaufenden Woche", () => {
// 21:00 UTC am Sonntag ist in Berlin 23:00 – noch derselbe Tag.
const sonntagAbend = new Date("2026-08-23T21:00:00Z");
expect(wocheUm(sonntagAbend).tage[0]).toBe("2026-08-17");
// Zwei Stunden spaeter ist Montag und damit die naechste Woche.
const montagFrueh = new Date("2026-08-23T22:30:00Z");
expect(wocheUm(montagFrueh).tage[0]).toBe("2026-08-24");
});
it("blättert rückwärts", () => {
expect(wocheUm(DIENSTAG, -1).tage[0]).toBe("2026-08-10");
expect(wocheUm(DIENSTAG, -3).tage[0]).toBe("2026-07-27");
});
it("kommt über den Monats- und Jahreswechsel", () => {
const neujahr = new Date("2027-01-01T12:00:00Z"); // ein Freitag
const woche = wocheUm(neujahr);
expect(woche.tage[0]).toBe("2026-12-28");
expect(woche.tage[6]).toBe("2027-01-03");
});
it("bleibt über die Zeitumstellung hinweg sieben Tage lang", () => {
// Am 25.10.2026 wird auf Winterzeit zurueckgestellt – die Woche hat
// dann 169 Stunden. "von + 7 Tage" waere hier eine Stunde daneben.
const inDerWoche = new Date("2026-10-21T12:00:00Z");
const woche = wocheUm(inDerWoche);
expect(woche.tage[0]).toBe("2026-10-19");
expect(woche.tage[6]).toBe("2026-10-25");
const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
expect(stunden).toBe(169);
});
it("liefert bei der Umstellung im Fruehjahr 167 Stunden", () => {
// Vorgestellt wird am letzten Sonntag im Maerz – 2026 ist das der 29.,
// er liegt also in der Woche ab Montag, dem 23.
const inDerWoche = new Date("2026-03-25T12:00:00Z");
const woche = wocheUm(inDerWoche);
expect(woche.tage[0]).toBe("2026-03-23");
expect(woche.tage[6]).toBe("2026-03-29");
const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
expect(stunden).toBe(167);
});
it("hat in einer Woche ohne Umstellung genau 168 Stunden", () => {
const woche = wocheUm(new Date("2026-03-31T12:00:00Z"));
expect(woche.tage[0]).toBe("2026-03-30");
expect((woche.bis.getTime() - woche.von.getTime()) / 3_600_000).toBe(168);
});
});
describe("Beschriftung", () => {
const DIENSTAG = new Date("2026-08-18T09:00:00Z");
it("nennt die laufende und die vorige Woche beim Namen", () => {
expect(wochenName(wocheUm(DIENSTAG))).toBe("Diese Woche");
expect(wochenName(wocheUm(DIENSTAG, -1))).toBe("Vorige Woche");
});
it("nennt weiter zurueckliegende Wochen mit Zeitraum", () => {
expect(wochenName(wocheUm(DIENSTAG, -2))).toMatch(/\d/);
});
it("nennt den Monat nur einmal, wenn die Woche nicht darueber hinausgeht", () => {
expect(wochenTitel(wocheUm(DIENSTAG))).toBe("17.–23. August 2026");
});
it("nennt beide Monate, wenn die Woche sie ueberspannt", () => {
// 31.08. bis 06.09.2026.
const woche = wocheUm(new Date("2026-09-02T12:00:00Z"));
expect(wochenTitel(woche)).toBe("31. August–6. September 2026");
});
});
describe("zaehleWoche", () => {
const eintrag = (
kind: Parameters<typeof zaehleWoche>[0][number]["kind"],
projectId: string,
meta?: unknown,
) => ({ kind, projectId, meta });
it("zaehlt Erledigtes am meta-Feld, nicht am Text", () => {
const zahlen = zaehleWoche([
eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DONE" }),
// Verschoben, aber nicht erledigt – darf nicht mitzaehlen.
eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DOING" }),
]);
expect(zahlen.erledigt).toBe(1);
});
it("zaehlt einen Eintrag ohne meta nicht als erledigt", () => {
// Aeltere Eintraege koennen kein "to" haben. Lieber untertreiben als
// eine Zahl behaupten, die aus dem Anzeigetext geraten waere.
expect(zaehleWoche([eintrag("TASK_MOVED", "p1")]).erledigt).toBe(0);
expect(zaehleWoche([eintrag("TASK_MOVED", "p1", "unsinn")]).erledigt).toBe(0);
});
it("zaehlt neue Aufgaben aus beiden Quellen", () => {
const zahlen = zaehleWoche([
eintrag("TASK_ADDED", "p1"),
eintrag("TASKS_IMPORTED", "p2"),
]);
expect(zahlen.neueAufgaben).toBe(2);
});
it("zaehlt beruehrte Projekte ohne Dubletten", () => {
const zahlen = zaehleWoche([
eintrag("NOTE_ADDED", "p1"),
eintrag("NOTE_ADDED", "p1"),
eintrag("REPO_COMMITS", "p2"),
]);
expect(zahlen.projekte).toBe(2);
expect(zahlen.notizen).toBe(2);
expect(zahlen.gesamt).toBe(3);
});
it("kommt mit einer leeren Woche zurecht", () => {
expect(zaehleWoche([])).toEqual({
gesamt: 0,
projekte: 0,
erledigt: 0,
neueAufgaben: 0,
notizen: 0,
});
});
});
+203
View File
@@ -0,0 +1,203 @@
import type { ActivityKind } from "@prisma/client";
/**
* Wochenrueckblick: welche Woche, und was zaehlt darin.
*
* Die Wochenrechnung steht hier als reine Funktion und nicht in der Seite –
* Wochengrenzen sind der Teil, der still falsch wird, und so laesst er sich
* pruefen.
*/
/** Wochenbeginn ist Montag, wie in Deutschland ueblich (ISO 8601). */
export const WOCHENBEGINN = 1;
/**
* Kalendertag in fester Zeitzone.
*
* Dieselbe Ueberlegung wie bei den Faelligkeiten in lib/tasks: im Container
* laeuft die Anwendung nach UTC. Um 23:30 Berliner Zeit waere dort noch der
* Vortag, und ein Eintrag von Sonntagabend fiele in die falsche Woche.
*/
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/** "2026-08-17" in Berliner Zeit. */
export function tagesschluessel(wert: Date): string {
return TAG_FORMAT.format(wert);
}
/** Wochentag in Berliner Zeit, 0 = Sonntag. */
function wochentag(wert: Date): number {
const [jahr, monat, tag] = tagesschluessel(wert).split("-").map(Number);
return new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay();
}
export interface Woche {
/** Beginn des Montags, als Zeitpunkt fuer die Abfrage. */
von: Date;
/** Beginn des folgenden Montags – ausschliesslich. */
bis: Date;
/** 0 = laufende Woche, -1 = vorige, … */
versatz: number;
/** Die sieben Kalendertage der Woche, Montag zuerst. */
tage: string[];
}
/**
* Grenzen der Woche, in der `jetzt` liegt – um `versatz` Wochen verschoben.
*
* Gerechnet wird ueber die Kalendertage in Berliner Zeit und dann in UTC
* zurueckgerechnet. Der Umweg ist noetig, weil zwischen zwei Montagen eine
* Zeitumstellung liegen kann: "sieben mal 24 Stunden" waere dann um eine
* Stunde daneben.
*/
export function wocheUm(jetzt: Date, versatz = 0): Woche {
const [jahr, monat, tag] = tagesschluessel(jetzt).split("-").map(Number);
const heuteUtc = Date.UTC(jahr, monat - 1, tag);
// Rueckwaerts bis zum Montag.
const abstandZumMontag = (wochentag(jetzt) - WOCHENBEGINN + 7) % 7;
const montag = new Date(heuteUtc - (abstandZumMontag - versatz * 7) * 86_400_000);
const tage: string[] = [];
for (let i = 0; i < 7; i += 1) {
tage.push(new Date(montag.getTime() + i * 86_400_000).toISOString().slice(0, 10));
}
const von = berlinMitternacht(tage[0]);
return {
von,
// Ende ist die Mitternacht *nach* dem Sonntag – nicht "von + 7 Tage":
// liegt eine Zeitumstellung dazwischen, ist die Woche 167 oder 169
// Stunden lang.
bis: berlinMitternacht(naechsterTag(tage[6])),
versatz,
tage,
};
}
/** Ein Kalendertag weiter, rein auf der Zeichenkette gerechnet. */
function naechsterTag(tag: string): string {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t) + 86_400_000).toISOString().slice(0, 10);
}
/**
* Um wie viel geht die Berliner Uhr zu diesem Zeitpunkt der UTC voraus?
*
* Ermittelt statt fest verdrahtet, weil zwischen Sommer- und Winterzeit eine
* Stunde Unterschied liegt und ein Rueckblick ueber die Umstellung hinweg
* sonst um eine Stunde verrutscht.
*/
function zonenversatzMs(zeitpunkt: Date): number {
// sv-SE liefert "2026-08-17 14:30:00" – nah genug an ISO, um es nach dem
// Austausch des Leerzeichens wieder einlesen zu koennen.
const berlin = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
dateStyle: "short",
timeStyle: "medium",
}).format(zeitpunkt);
return Date.parse(`${berlin.replace(" ", "T")}Z`) - zeitpunkt.getTime();
}
/** Der Zeitpunkt, an dem in Berlin dieser Kalendertag beginnt. */
export function berlinMitternacht(tag: string): Date {
const basis = new Date(`${tag}T00:00:00Z`);
// Die Umstellung findet nachts um zwei statt, nie um Mitternacht – der
// Versatz an dieser Stelle ist also eindeutig.
return new Date(basis.getTime() - zonenversatzMs(basis));
}
export interface Kennzahlen {
gesamt: number;
projekte: number;
erledigt: number;
neueAufgaben: number;
notizen: number;
}
/** Was ein Verlaufseintrag fuer die Zaehlung hergeben muss. */
export interface ZaehlbarerEintrag {
kind: ActivityKind;
projectId: string;
meta?: unknown;
}
/**
* Kennzahlen einer Woche.
*
* "Erledigt" kommt aus dem `meta`-Feld (`to: "DONE"`) und nicht aus dem
* Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
* Formulierung – eine Zaehlung, die daran haengt, wird beim naechsten
* umformulierten Einzeiler still falsch.
*
* Fuer aeltere Eintraege ohne `to` bleibt ein Rueckfall auf den Text: der
* Verlauf haelt bis zu 200 Eintraege je Projekt, darunter also durchaus
* welche von vor dieser Aenderung.
*/
export function zaehleWoche(eintraege: ZaehlbarerEintrag[]): Kennzahlen {
let erledigt = 0;
let neueAufgaben = 0;
let notizen = 0;
const projekte = new Set<string>();
for (const eintrag of eintraege) {
projekte.add(eintrag.projectId);
if (eintrag.kind === "TASK_ADDED" || eintrag.kind === "TASKS_IMPORTED") {
neueAufgaben += 1;
} else if (eintrag.kind === "NOTE_ADDED") {
notizen += 1;
} else if (eintrag.kind === "TASK_MOVED" && istErledigt(eintrag)) {
erledigt += 1;
}
}
return { gesamt: eintraege.length, projekte: projekte.size, erledigt, neueAufgaben, notizen };
}
function istErledigt(eintrag: ZaehlbarerEintrag): boolean {
const meta = eintrag.meta;
if (meta && typeof meta === "object" && "to" in meta) {
return (meta as { to?: unknown }).to === "DONE";
}
return false;
}
const MONAT_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
month: "long",
});
/**
* Beschriftung einer Woche: "17.–23. August 2026", ueber einen
* Monatswechsel hinweg "31. August–6. September 2026".
*
* Der Punkt hinter der Tageszahl kommt von Hand: Intl liefert bei
* `day: "numeric"` nur die Ziffer, und mit `day: "2-digit"` staende dort
* "07." statt "7.".
*/
export function wochenTitel(woche: Woche): string {
const teile = (tag: string) => {
const [j, m, t] = tag.split("-").map(Number);
return { tag: t, monat: MONAT_FORMAT.format(new Date(Date.UTC(j, m - 1, t, 12))), jahr: j };
};
const von = teile(woche.tage[0]);
const bis = teile(woche.tage[6]);
const gleicherMonat = von.monat === bis.monat && von.jahr === bis.jahr;
const links = gleicherMonat ? `${von.tag}.` : `${von.tag}. ${von.monat}`;
return `${links}–${bis.tag}. ${bis.monat} ${bis.jahr}`;
}
/** "Diese Woche", "Vorige Woche", sonst der Zeitraum. */
export function wochenName(woche: Woche): string {
if (woche.versatz === 0) return "Diese Woche";
if (woche.versatz === -1) return "Vorige Woche";
return wochenTitel(woche);
}
+22 -1
View File
@@ -5,6 +5,7 @@ import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types"; import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify"; import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories"; import { repoTitle } from "@/lib/repositories";
import { reconcileLinkedIssues } from "@/lib/tasks/issues";
import type { RepoSnapshotData } from "@/lib/git/types"; import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult { export interface SyncResult {
@@ -62,11 +63,31 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
// README hineingeschrieben werden koennen. // README hineingeschrieben werden koennen.
await maybeRefreshCalendar(repository.id, client, ref); await maybeRefreshCalendar(repository.id, client, ref);
await maybeRefreshReadme(repository.id, client, ref); await maybeRefreshReadme(repository.id, client, ref);
/**
* Verknuepfte Aufgaben nachziehen: was in Git geschlossen wurde, wird
* hier abgehakt. Der Webhook macht das sofort – ist keiner eingerichtet
* oder faellt er aus, greift der Abgleich.
*
* Die offenen Issues aus dem gerade geholten Snapshot gehen mit hinein:
* fuer jede Nummer, die dort steht, ist die Nachfrage gespart.
*/
const abgehakt = await reconcileLinkedIssues(
repository.id,
client,
ref,
data.issues.map((issue) => issue.number),
);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht. // Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.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.` +
(abgehakt > 0
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
: ""),
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { issueShouldBeOpen, statusForIssueState } from "./issues";
describe("issueShouldBeOpen", () => {
it("haelt das Issue offen, solange die Aufgabe nicht erledigt ist", () => {
expect(issueShouldBeOpen("TODO")).toBe(true);
expect(issueShouldBeOpen("DOING")).toBe(true);
expect(issueShouldBeOpen("BLOCKED")).toBe(true);
});
it("schliesst es erst bei ERLEDIGT", () => {
expect(issueShouldBeOpen("DONE")).toBe(false);
});
});
describe("statusForIssueState", () => {
it("macht aus einem geschlossenen Issue eine erledigte Aufgabe", () => {
expect(statusForIssueState(false, "TODO")).toBe("DONE");
expect(statusForIssueState(false, "DOING")).toBe("DONE");
});
it("holt eine erledigte Aufgabe zurueck nach OFFEN", () => {
expect(statusForIssueState(true, "DONE")).toBe("TODO");
});
it("laesst den feineren Zustand stehen, wenn das Issue ohnehin offen ist", () => {
// Ein "opened"-Ereignis zu einer Aufgabe, die schon in Arbeit ist, darf
// sie nicht auf OFFEN zuruecksetzen – IN ARBEIT ist mehr Information.
expect(statusForIssueState(true, "DOING")).toBe("DOING");
expect(statusForIssueState(true, "BLOCKED")).toBe("BLOCKED");
expect(statusForIssueState(true, "TODO")).toBe("TODO");
});
it("bildet den Rundlauf ab, so verlustbehaftet wie er ist", () => {
// IN ARBEIT geht nach draussen als "offen". Kommt es zurueck, bleibt es
// IN ARBEIT – der Zustand ueberlebt also, solange niemand das Issue
// schliesst. Nach dem Schliessen und Wiederoeffnen ist er weg, und das
// ist die bewusst hingenommene Grenze.
const hin = issueShouldBeOpen("DOING");
expect(statusForIssueState(hin, "DOING")).toBe("DOING");
expect(statusForIssueState(true, statusForIssueState(false, "DOING"))).toBe("TODO");
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { TaskStatus } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git";
import type { GitClient, RepoRef } from "@/lib/git/types";
import { nextPosition, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
/**
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
*
* Die Zustaende passen nicht aufeinander und das bleibt so: Nebula kennt
* OFFEN, IN ARBEIT, BLOCKIERT und ERLEDIGT, ein Issue nur offen oder
* geschlossen. Nach aussen geht deshalb nur die Grenze zwischen "erledigt"
* und "nicht erledigt"; ein Rundlauf verliert IN ARBEIT und BLOCKIERT.
*
* Die Alternative waeren Labels wie "nebula:doing" – die wuerden den
* Issue-Tracker aber mit Zustaenden fuellen, die dort niemand pflegt. Die
* feine Abstufung bleibt in Nebula.
*/
/** Ist der Aufgabenzustand einer, bei dem das Issue offen sein soll? */
export function issueShouldBeOpen(status: TaskStatus): boolean {
return status !== "DONE";
}
/** Der Aufgabenzustand, der zu einem Issue-Zustand passt. */
export function statusForIssueState(open: boolean, current: TaskStatus): TaskStatus {
if (open) {
// Ein wieder geoeffnetes Issue macht aus ERLEDIGT wieder OFFEN. Stand
// die Aufgabe ohnehin nicht auf ERLEDIGT, bleibt ihr feinerer Zustand
// stehen – IN ARBEIT ist mehr Information als OFFEN.
return current === "DONE" ? "TODO" : current;
}
return "DONE";
}
/**
* Traegt den Zustand einer Aufgabe an ihrem Issue nach.
*
* Bewusst fehlertolerant: schlaegt der Aufruf fehl, ist die Aufgabe in
* Nebula trotzdem umgezogen. Ein nicht erreichbares Git-System darf das
* Abhaken nicht verhindern – der naechste Wechsel versucht es erneut.
*
* Gibt zurueck, ob es geklappt hat; null heisst "nichts zu tun".
*/
export async function mirrorStatusToIssue(
taskId: string,
status: TaskStatus,
): Promise<boolean | null> {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { sourceIssue: true, sourceRepositoryId: true },
});
if (!task?.sourceIssue || !task.sourceRepositoryId) return null;
const repository = await prisma.repository.findUnique({
where: { id: task.sourceRepositoryId },
include: { connection: true },
});
if (!repository?.connection || !repository.repoOwner || !repository.repoName) return null;
try {
const client = clientForConnection(repository.connection);
await client.setIssueState(
{ owner: repository.repoOwner, name: repository.repoName },
task.sourceIssue,
issueShouldBeOpen(status),
);
return true;
} catch {
return false;
}
}
/**
* Hakt eine Aufgabe ab, weil ihr Issue geschlossen wurde.
*
* Bewusst an einer Stelle fuer beide Wege – Webhook und Abgleich. Vorher
* setzte der Webhook nur den Status und liess dabei aus, was die Oberflaeche
* tut: die naechste Fassung einer wiederkehrenden Aufgabe anlegen und den
* Vorgang in den Verlauf schreiben.
*
* Gibt false zurueck, wenn nichts zu tun war (Aufgabe fehlt oder ist schon
* erledigt) – der Aufrufer kann daran zaehlen, was er bewirkt hat.
*/
export async function completeTaskFromIssue(
taskId: string,
herkunft: "webhook" | "abgleich",
): Promise<boolean> {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { id: true, title: true, status: true, projectId: true, sourceIssue: true },
});
if (!task || task.status === "DONE") return false;
await prisma.task.update({
where: { id: task.id },
data: {
status: "DONE",
doneAt: new Date(),
position: await nextPosition(task.projectId, "DONE"),
},
});
// Wie beim Abhaken von Hand: eine wiederkehrende Aufgabe erzeugt ihre
// naechste Fassung.
const folge = await spawnRecurrence(task.id);
await refreshDerivedProgress(task.projectId);
await recordActivity({
projectId: task.projectId,
// Kein userId: das hat nicht ein Mensch in Nebula getan, sondern das
// Git-System gemeldet.
kind: "TASK_MOVED",
summary:
`Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` +
(herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") +
(folge ? ", nächste angelegt" : ""),
// "from"/"to" wie beim Verschieben von Hand – der Wochenrueckblick
// zaehlt daran ab, was erledigt wurde, statt den Text zu durchsuchen.
meta: {
taskId: task.id,
issue: task.sourceIssue,
source: herkunft,
from: task.status,
to: "DONE",
},
});
return true;
}
/** Wie viele Issues ein Abgleich hoechstens einzeln nachfragt. */
const MAX_NACHFRAGEN = 25;
/**
* Gleicht beim Abgleich die verknuepften Issues ab: was in Git geschlossen
* ist, wird in Nebula abgehakt.
*
* Warum zusaetzlich zum Webhook: der Webhook wirkt sofort, setzt aber voraus,
* dass die Instanz von aussen erreichbar ist und der Haken eingetragen wurde.
* Ist er das nicht – oder faellt er einmal aus –, bliebe der Rueckweg sonst
* ganz aus. Hier kommt er verzoegert, aber verlaesslich.
*
* **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
* Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
* Nebula aus fehlgeschlagen ist – dann wuerde ein symmetrischer Abgleich eine
* bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
* Webhook vorbehalten, der ein echtes "reopened" sieht.
*
* Fehler werden geschluckt: ein nicht erreichbares Issue darf den Abgleich
* des Repositories nicht scheitern lassen.
*/
export async function reconcileLinkedIssues(
repositoryId: string,
client: GitClient,
ref: RepoRef,
/** Nummern, die der Snapshot gerade als offen gemeldet hat. */
offeneNummern: number[] = [],
): Promise<number> {
const offen = new Set(offeneNummern);
const kandidaten = await prisma.task.findMany({
where: {
sourceRepositoryId: repositoryId,
sourceIssue: { not: null },
status: { not: "DONE" },
},
select: { id: true, sourceIssue: true },
orderBy: { createdAt: "asc" },
});
let abgehakt = 0;
let nachfragen = 0;
for (const task of kandidaten) {
const nummer = task.sourceIssue;
if (nummer === null) continue;
// Steht die Nummer in der Liste der offenen Issues, ist nichts zu tun –
// und der Aufruf gespart. Umgekehrt gilt das nicht: die Liste ist
// gedeckelt, aus dem Fehlen folgt also nichts.
if (offen.has(nummer)) continue;
if (nachfragen >= MAX_NACHFRAGEN) break;
nachfragen += 1;
try {
const zustand = await client.getIssueState(ref, nummer);
// null heisst "gibt es nicht mehr" – geloescht ist nicht erledigt,
// also bleibt die Aufgabe stehen.
if (zustand !== "closed") continue;
if (await completeTaskFromIssue(task.id, "abgleich")) abgehakt += 1;
} catch {
// Nichts erreichbar, nichts geaendert – der naechste Lauf versucht es.
}
}
return abgehakt;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { SEITE, SEITE_MAX, cursorAus, seitenBedingung, seitenGroesse } from "./timeline";
const ID = "cmstxnrzs00011cx4nd5tiqbt";
describe("cursorAus", () => {
it("liest Zeitpunkt und ID", () => {
const cursor = cursorAus("2026-08-17T22:30:00.000Z", ID);
expect(cursor?.id).toBe(ID);
expect(cursor?.createdAt.toISOString()).toBe("2026-08-17T22:30:00.000Z");
});
it("faengt ohne Cursor von vorn an", () => {
expect(cursorAus(null, null)).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", null)).toBeNull();
expect(cursorAus(null, ID)).toBeNull();
});
it("weist Unbrauchbares ab, statt es in die Abfrage zu geben", () => {
expect(cursorAus("kein datum", ID)).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", "kurz")).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", "'; DROP TABLE x --")).toBeNull();
});
});
describe("seitenBedingung", () => {
it("filtert immer auf den Besitzer", () => {
expect(seitenBedingung("u1", null)).toEqual({ project: { ownerId: "u1" } });
});
it("behaelt den Besitzer auch mit Cursor", () => {
const wo = seitenBedingung("u1", { createdAt: new Date("2026-08-17T22:30:00Z"), id: ID });
expect(wo.project).toEqual({ ownerId: "u1" });
});
it("fasst gleichzeitige Eintraege ueber die ID auseinander", () => {
// Der eigentliche Zweck: beim Uebernehmen von Issues entstehen mehrere
// Zeilen mit identischem createdAt. Ohne den zweiten Zweig wuerde die
// naechste Seite sie ueberspringen oder wiederholen.
const zeitpunkt = new Date("2026-08-17T22:30:00Z");
const wo = seitenBedingung("u1", { createdAt: zeitpunkt, id: ID });
expect(wo.OR).toEqual([
{ createdAt: { lt: zeitpunkt } },
{ createdAt: zeitpunkt, id: { lt: ID } },
]);
});
});
describe("seitenGroesse", () => {
it("nimmt die Vorgabe, wenn nichts angefragt wurde", () => {
expect(seitenGroesse(null)).toBe(SEITE);
expect(seitenGroesse("unsinn")).toBe(SEITE);
});
it("deckelt nach oben und unten", () => {
expect(seitenGroesse("1000")).toBe(SEITE_MAX);
expect(seitenGroesse("0")).toBe(1);
expect(seitenGroesse("-5")).toBe(1);
});
it("nimmt einen gueltigen Wert an", () => {
expect(seitenGroesse("25")).toBe(25);
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { Prisma } from "@prisma/client";
/**
* Fortlaufender Verlauf ueber alle Projekte.
*
* Geblaettert wird per Cursor, nicht per `skip`. Der Verlauf schreibt
* mehrere Eintraege im selben Augenblick – beim Uebernehmen von Issues etwa
* entstehen zehn Zeilen mit identischem `createdAt`. Ein Offset ueber eine
* solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal, und zwar
* lautlos. Der Cursor aus (createdAt, id) ist dagegen eindeutig.
*/
/** Wie viele Eintraege eine Seite hat. */
export const SEITE = 40;
/** Deckel gegen einen Aufrufer, der sich alles auf einmal holen will. */
export const SEITE_MAX = 100;
export interface Cursor {
/** Zeitpunkt des letzten gezeigten Eintrags. */
createdAt: Date;
/** Seine ID – entscheidet bei gleichem Zeitpunkt. */
id: string;
}
/**
* Liest einen Cursor aus Abfrageparametern.
*
* Gibt null zurueck, wenn nichts oder Unbrauchbares ankommt – dann faengt
* die Seite eben von vorn an. Ein Fehler waere hier die schlechtere Antwort:
* ein abgeschnittener Verweis soll die Seite nicht sperren.
*/
export function cursorAus(vor: string | null, id: string | null): Cursor | null {
if (!vor || !id) return null;
const zeitpunkt = new Date(vor);
if (Number.isNaN(zeitpunkt.getTime())) return null;
// Die ID ist eine cuid; alles andere weisen wir ab, statt es in die
// Abfrage zu geben.
if (!/^[a-z0-9]{20,32}$/i.test(id)) return null;
return { createdAt: zeitpunkt, id };
}
/**
* Die where-Bedingung fuer eine Seite.
*
* Besitzer zuerst – wie ueberall im Projekt, damit fremde Datensaetze gar
* nicht erst getroffen werden. Der Verlauf selbst kennt keinen Besitzer, die
* Bedingung laeuft deshalb ueber das Projekt.
*
* Der Cursor bedeutet "aelter als der letzte gezeigte Eintrag": entweder
* frueher, oder gleichzeitig und mit kleinerer ID. Genau so sortiert auch
* die Abfrage, weshalb beides zusammenpasst.
*/
export function seitenBedingung(ownerId: string, cursor: Cursor | null): Prisma.ActivityWhereInput {
const basis: Prisma.ActivityWhereInput = { project: { ownerId } };
if (!cursor) return basis;
return {
...basis,
OR: [
{ createdAt: { lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { lt: cursor.id } },
],
};
}
/** Sortierung, die zur Cursor-Bedingung passt. Beide muessen gleich sein. */
export const SEITEN_ORDNUNG: Prisma.ActivityOrderByWithRelationInput[] = [
{ createdAt: "desc" },
{ id: "desc" },
];
/** Begrenzt eine gewuenschte Seitengroesse auf das Erlaubte. */
export function seitenGroesse(roh: string | null): number {
const angefragt = roh === null ? Number.NaN : Number(roh);
if (!Number.isFinite(angefragt)) return SEITE;
return Math.min(Math.max(Math.trunc(angefragt), 1), SEITE_MAX);
}
+137
View File
@@ -0,0 +1,137 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { parseEvent, verifySignature } from "./verify";
const GEHEIM = "ein-geheimnis-das-lang-genug-ist";
const RUMPF = JSON.stringify({ action: "closed", issue: { number: 12 } });
function signiere(secret: string, body: string): string {
return createHmac("sha256", secret).update(body, "utf8").digest("hex");
}
function kopf(eintraege: Record<string, string>): Headers {
return new Headers(eintraege);
}
describe("verifySignature – GitHub", () => {
it("nimmt die richtige Signatur an", () => {
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("weist eine fremde Signatur ab", () => {
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere("anderes", RUMPF)}` });
expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({
ok: false,
reason: "mismatch",
});
});
it("weist ab, wenn der Rumpf veraendert wurde", () => {
// Das ist der eigentliche Zweck: die Signatur haengt am Inhalt.
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
const veraendert = JSON.stringify({ action: "closed", issue: { number: 99 } });
expect(verifySignature("GITHUB", GEHEIM, veraendert, headers).ok).toBe(false);
});
it("weist ab, wenn gar keine Signatur mitkommt", () => {
expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({}))).toEqual({
ok: false,
reason: "missing",
});
});
it("laesst sich nicht mit dem blossen Praefix taeuschen", () => {
expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({ "x-hub-signature-256": "sha256=" })).ok).toBe(
false,
);
});
});
describe("verifySignature – Gitea", () => {
it("nimmt die Signatur ohne Praefix an", () => {
const headers = kopf({ "x-gitea-signature": signiere(GEHEIM, RUMPF) });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("nimmt ersatzweise das GitHub-Format an", () => {
// Aeltere Gitea-Fassungen senden nur X-Hub-Signature-256.
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("weist eine falsche Signatur ab", () => {
const headers = kopf({ "x-gitea-signature": signiere("anderes", RUMPF) });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers).ok).toBe(false);
});
});
describe("verifySignature – GitLab", () => {
it("vergleicht den Token im Klartext", () => {
expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": GEHEIM }))).toEqual({
ok: true,
});
});
it("weist einen falschen Token ab", () => {
expect(
verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": "falsch" })).ok,
).toBe(false);
});
it("weist ab, wenn der Token fehlt", () => {
expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({}))).toEqual({
ok: false,
reason: "missing",
});
});
});
describe("parseEvent", () => {
it("erkennt einen Push", () => {
expect(parseEvent("GITHUB", "push", {})).toEqual({ kind: "push" });
});
it("erkennt ein geschlossenes Issue bei GitHub und Gitea", () => {
const nutzlast = { action: "closed", issue: { number: 12 } };
expect(parseEvent("GITHUB", "issues", nutzlast)).toEqual({
kind: "issue",
number: 12,
open: false,
});
expect(parseEvent("GITEA", "issues", nutzlast)).toEqual({
kind: "issue",
number: 12,
open: false,
});
});
it("erkennt ein wieder geoeffnetes Issue", () => {
expect(parseEvent("GITHUB", "issues", { action: "reopened", issue: { number: 7 } })).toEqual({
kind: "issue",
number: 7,
open: true,
});
});
it("nimmt bei GitLab die projektinterne Nummer", () => {
// "iid" ist die Nummer aus der Oberflaeche; "id" waere instanzweit.
const ereignis = parseEvent("GITLAB", "Issue Hook", {
object_kind: "issue",
object_attributes: { iid: 5, state: "closed" },
});
expect(ereignis).toEqual({ kind: "issue", number: 5, open: false });
});
it("laesst Unbekanntes liegen, statt zu scheitern", () => {
expect(parseEvent("GITHUB", "issue_comment", {}).kind).toBe("ignored");
expect(parseEvent("GITHUB", "issues", { action: "labeled", issue: { number: 1 } }).kind).toBe(
"ignored",
);
expect(parseEvent("GITHUB", null, null).kind).toBe("ignored");
});
it("laesst ein Issue ohne Nummer liegen", () => {
expect(parseEvent("GITHUB", "issues", { action: "closed", issue: {} }).kind).toBe("ignored");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { GitProvider } from "@prisma/client";
/**
* Pruefung eingehender Webhooks.
*
* Der Endpunkt ist die einzige Stelle der Anwendung, die ohne Anmeldung
* schreibend wirkt – deshalb steht die Pruefung hier fuer sich, ohne
* Datenbank und ohne Netz, und ist damit vollstaendig testbar.
*
* Die drei Systeme machen es unterschiedlich:
*
* GitHub X-Hub-Signature-256: "sha256=<hex>", HMAC-SHA256 ueber den Rumpf
* Gitea X-Gitea-Signature: "<hex>" ohne Praefix, sonst gleich
* GitLab X-Gitlab-Token: das Geheimnis im Klartext, keine Signatur
*
* GitLabs Verfahren ist schwaecher – es beweist nur, dass der Absender das
* Geheimnis kennt, nicht dass der Rumpf unveraendert ist. Mehr gibt die
* Plattform nicht her; verglichen wird trotzdem zeitkonstant.
*/
export type VerifyResult =
| { ok: true }
| { ok: false; reason: "missing" | "mismatch" | "unsupported" };
/** Vergleich ohne Laufzeitunterschied – ein Zeichenvergleich waere angreifbar. */
function equals(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
// timingSafeEqual verlangt gleiche Laenge und wirft sonst. Die Laenge
// selbst ist kein Geheimnis, der fruehe Ausstieg also unbedenklich.
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
function hmacHex(secret: string, body: string): string {
return createHmac("sha256", secret).update(body, "utf8").digest("hex");
}
/**
* Prueft die Signatur eines eingehenden Aufrufs.
*
* `body` muss der **rohe** Rumpf sein, genau wie er ankam. Ein durch
* JSON.parse und JSON.stringify gedrehter Rumpf hat eine andere Signatur –
* andere Reihenfolge, anderes Escaping, kein Leerraum.
*/
export function verifySignature(
provider: GitProvider,
secret: string,
body: string,
headers: Headers,
): VerifyResult {
switch (provider) {
case "GITHUB": {
const gesendet = headers.get("x-hub-signature-256");
if (!gesendet) return { ok: false, reason: "missing" };
const erwartet = `sha256=${hmacHex(secret, body)}`;
return equals(gesendet, erwartet) ? { ok: true } : { ok: false, reason: "mismatch" };
}
case "GITEA": {
// Gitea schickt die Signatur ohne Praefix. Aeltere Fassungen senden
// zusaetzlich X-Hub-Signature-256 im GitHub-Format – beides gilt.
const gitea = headers.get("x-gitea-signature");
if (gitea) {
return equals(gitea, hmacHex(secret, body))
? { ok: true }
: { ok: false, reason: "mismatch" };
}
const hub = headers.get("x-hub-signature-256");
if (hub) {
return equals(hub, `sha256=${hmacHex(secret, body)}`)
? { ok: true }
: { ok: false, reason: "mismatch" };
}
return { ok: false, reason: "missing" };
}
case "GITLAB": {
const token = headers.get("x-gitlab-token");
if (!token) return { ok: false, reason: "missing" };
return equals(token, secret) ? { ok: true } : { ok: false, reason: "mismatch" };
}
default:
return { ok: false, reason: "unsupported" };
}
}
/** Welches Ereignis kam an – auf einen gemeinsamen Nenner gebracht. */
export type WebhookEvent =
| { kind: "push" }
| { kind: "issue"; number: number; open: boolean }
| { kind: "ignored"; name: string };
interface IssuePayload {
action?: string;
issue?: { number?: number; iid?: number; state?: string };
object_attributes?: { iid?: number; action?: string; state?: string };
object_kind?: string;
}
/**
* Uebersetzt den Rumpf in ein Ereignis, das die Anwendung kennt.
*
* Bewusst grob: Nebula interessiert nur, ob ein Push kam (dann lohnt ein
* Abgleich) und ob ein Issue geschlossen oder wieder geoeffnet wurde (dann
* wandert die Aufgabe mit). Alles andere wird benannt und liegen gelassen.
*/
export function parseEvent(
provider: GitProvider,
eventName: string | null,
payload: unknown,
): WebhookEvent {
const daten = (payload ?? {}) as IssuePayload;
const name = (eventName ?? daten.object_kind ?? "").toLowerCase();
if (name === "push" || name === "push hook") return { kind: "push" };
if (provider === "GITLAB") {
if (name !== "issue" && name !== "issue hook") return { kind: "ignored", name };
const attrs = daten.object_attributes;
const nummer = attrs?.iid;
if (typeof nummer !== "number") return { kind: "ignored", name };
// GitLab meldet den Zustand als "opened" oder "closed".
const offen = attrs?.state !== "closed";
return { kind: "issue", number: nummer, open: offen };
}
// GitHub und Gitea nennen das Ereignis "issues" und legen die Tat in
// "action" ab. Umbenennungen und Kommentare interessieren hier nicht.
if (name !== "issues") return { kind: "ignored", name };
const nummer = daten.issue?.number;
if (typeof nummer !== "number") return { kind: "ignored", name };
if (daten.action !== "closed" && daten.action !== "reopened" && daten.action !== "opened") {
return { kind: "ignored", name: `issues.${daten.action ?? "?"}` };
}
return { kind: "issue", number: nummer, open: daten.action !== "closed" };
}
+25 -4
View File
@@ -15,11 +15,27 @@ import { NextResponse, type NextRequest } from "next/server";
*/ */
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/**
* Beide moeglichen Namen des Sitzungscookies.
*
* Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix
* verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand
* der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es
* geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet
* requireUserPage bzw. requireUserApi.
*/
const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"];
/** Ohne Anmeldung erreichbar. */ /** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"]; const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"]; /**
* Eingehende Webhooks bringen kein Cookie mit – sie kommen von GitHub,
* GitLab oder Gitea. Ausgewiesen wird sich dort mit einer Signatur ueber den
* Rumpf, geprueft im Endpunkt selbst (lib/webhooks/verify). Ohne gueltiges
* Geheimnis passiert nichts.
*/
const PUBLIC_API_PREFIXES = ["/api/auth/", "/api/webhooks/"];
/** /**
* Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der * Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite * Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
@@ -91,7 +107,12 @@ export function middleware(request: NextRequest) {
const isDev = process.env.NODE_ENV === "development"; const isDev = process.env.NODE_ENV === "development";
// ── 1. CSRF ──────────────────────────────────────────────── // ── 1. CSRF ────────────────────────────────────────────────
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) { // Webhooks kommen von einem fremden Server und koennen keinen passenden
// Origin mitbringen. Die Herkunftspruefung schuetzt gegen den Browser
// eines angemeldeten Nutzers – hier gibt es keinen, und ausgewiesen wird
// sich stattdessen mit der Signatur ueber den Rumpf.
const istWebhook = pathname.startsWith("/api/webhooks/");
if (!istWebhook && !SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
return NextResponse.json( return NextResponse.json(
{ error: "Anfrage von fremdem Ursprung abgelehnt." }, { error: "Anfrage von fremdem Ursprung abgelehnt." },
{ status: 403 }, { status: 403 },
@@ -99,7 +120,7 @@ export function middleware(request: NextRequest) {
} }
// ── 2. Zugangsschranke ───────────────────────────────────── // ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value); const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value));
if (!hasSession && !isPublicPath(pathname)) { if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) { if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }); return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
+28 -3
View File
@@ -40,8 +40,14 @@ export interface ConnectionWithCount extends ConnectionSafe {
_count?: { repositories: number }; _count?: { repositories: number };
} }
/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */ /**
export interface RepositoryWithSnapshot extends Repository { * Ein Repository samt Verbindung und zuletzt geholtem Stand.
*
* Ohne webhookSecret: das Geheimnis verlaesst den Server nicht – siehe
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
* getrennt durch.
*/
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
connection: ConnectionSafe | null; connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null; snapshot: RepoSnapshot | null;
} }
@@ -87,12 +93,31 @@ export interface TaskOverviewItem {
dueDate: string | null; dueDate: string | null;
labels: string[]; labels: string[];
recurrence: Recurrence | null; recurrence: Recurrence | null;
/** Gesetzt, wenn die Aufgabe aus einem Git-Issue stammt. */ /** Gesetzt, wenn die Aufgabe an einem Git-Issue haengt. */
sourceIssue: number | null;
sourceUrl: string | null; sourceUrl: string | null;
bucket: DueBucket; bucket: DueBucket;
project: { id: string; name: string; accent: string; status: ProjectStatus }; project: { id: string; name: string; accent: string; status: ProjectStatus };
} }
/** Ein Verlaufseintrag im Wochenrueckblick – mit Projekt, ueber Projekte hinweg. */
export interface ReviewEntry {
id: string;
kind: ActivityKind;
summary: string;
/** ISO – ein Date ueberlebt den Weg zum Browser nicht. */
createdAt: string;
project: { id: string; name: string; accent: string };
user: { username: string; displayName: string | null } | null;
}
/** Ein Kalendertag der Woche samt seiner Eintraege. */
export interface ReviewDay {
/** "2026-08-17", in Berliner Zeit bestimmt. */
tag: string;
eintraege: ReviewEntry[];
}
export interface RepoListEntry { export interface RepoListEntry {
fullPath: string; fullPath: string;
owner: string; owner: string;