50 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
florian.wildeandClaude Opus 5 c9202fb65e chore(release): v1.6.0
CI / pruefen (push) Canceled after 0s
Der Server hat den neuen Stand ausgeliefert und in der Fusszeile weiter
"v1.5.0" angezeigt: Listenansicht, Schnellerfassung und die Aufgabenseite
waren drin, die Versionsnummer nicht.

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. Genau das war hier der Fall.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was sich geaendert hat: die
mobile Ansicht, die vierte Ansicht im Board, die Schnellerfassung, die
Aufgaben ueber alle Projekte, 16px in Eingabefeldern gegen den iOS-Zoom,
die fehlenden Beschriftungen der Symbolknoepfe und das beschnittene
Tag-Formular.

In der Roadmap ist v1.6.0 als erledigt eingetragen. Offen bleibt daraus
nur der dritte Teil, die persoenlichen API-Token - die sind
sicherheitsrelevant und bekommen einen eigenen Durchgang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:56:03 +02:00
florian.wildeandClaude Opus 5 5ae1b6ed06 feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
CI / pruefen (push) Canceled after 0s
Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner
aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft.

**Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite
erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im
Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas
kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu
aendern.

Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen
waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt
wird gemerkt.

Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten
rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste
kollidiert, die beim Markieren von Projekten einfaehrt.

**Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt.
Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter
Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der
bestehende Endpunkt.

Zwei Entscheidungen, die im Code begruendet sind:

- Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und
  nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht
  ueberfaellig, sondern immer noch "heute". Und im Container laeuft die
  Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um
  einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und
  Hydration auseinander.
- Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer
  Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge
  anzubieten, die nirgends ankommt, waere ein leeres Versprechen.

Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als einzige und erste Bedingung.

Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten
Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm
jetzt weg.

Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des
UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber
Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:33:16 +02:00
florian.wildeandClaude Opus 5 fcc903601a docs(roadmap): v1.6.0 und v1.7.0 aufgenommen
Zwei Vorhaben, die sich aus der Durchsicht der mobilen Ansicht ergeben haben.

v1.6.0 buendelt Schnellerfassung, eine projektuebergreifende Aufgabenseite
und die persoenlichen API-Token: erfassen am Telefon, ueberblicken an einer
Stelle, ansprechen per Shortcut. Die beiden Fallstricke der Aufgabenseite
stehen dabei: Task hat kein ownerId, und position gilt nur je Projekt.

v1.7.0 verknuepft Aufgaben mit Git-Issues. Die Zuordnung steht im Schema
laengst (sourceIssue, sourceUrl, sourceRepositoryId); es fehlt nur die
Gegenrichtung zur bestehenden Uebernahme. Bewusst ueber die HTTP-API der
Provider, nicht ueber eigene Git-Operationen - der Ausschluss unter
"Bewusst nicht" bleibt also unangetastet.

Ausdruecklich festgehalten, was NICHT kommt: eine echte beidseitige
Synchronisation. Die Zustaende passen ohnehin nicht aufeinander - ein Issue
kennt nur offen und geschlossen, DOING und BLOCKED gehen im Rundlauf
verloren. Das wird hingenommen, statt den Issue-Tracker mit Labels zu
fuellen, die dort niemand pflegt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:32:53 +02:00
florian.wildeandClaude Opus 5 f3c3383231 feat(board): Listenansicht, und die mobile Ansicht laeuft nicht mehr aus dem Bild
CI / pruefen (push) Canceled after 0s
Auf 375px war das Dashboard 40px breiter als der Bildschirm. Ursache war die
Steuerleiste: Suchfeld, Sortierung, Ansichtsumschalter und "Neues Projekt"
lagen in einer Flex-Zeile ohne Umbruch, und weil das Sortierfeld ein
min-width mitbringt, wurde der Knopf am Ende schlicht hinausgeschoben. Das
overflow-x: hidden am body hat ihn dann abgeschnitten statt erreichbar zu
lassen. Dasselbe Muster im Projektkopf, wo "Loeschen" ausserhalb lag.

Die Zeilen brechen jetzt um, der Ansichtsumschalter steht auf schmalen
Anzeigen ueber die volle Breite. Gemessen ueber alle Seiten und beide
Testbreiten: scrollWidth == clientWidth.

Weil der Umschalter ohnehin umgebaut wurde, kommt die vierte Ansicht gleich
mit: eine Zeile je Projekt statt einer Karte. Auf dem Telefon ist das der
eigentliche Gewinn — eine Karte braucht rund 260px Hoehe und steht dort
zwangslaeufig allein in der Zeile, fuenf Projekte fuellen also zwei
Bildschirme. Als Liste passen sie auf einen, und weil alle Zeilen gleich
gebaut sind, liegen Name, Fortschritt und Status untereinander. Was nicht
mehr in die Breite passt, faellt der Reihe nach weg, statt sie zu sprengen.

Dazu die Kleinigkeiten, die auf dem Telefon dranhaengen:

- .field bekommt auf Beruehrungsgeraeten 16px. Darunter zoomt iOS Safari
  beim Hineintippen die Seite heran und kehrt nicht von selbst zurueck.
- .btn mindestens 44px hoch, der Schieberegler einen 24px-Griff.
- Der Popover klemmt seine Breite ans Fenster. Unter 280px lieferte die
  Rechnung eine negative linke Kante, das Menue stand halb ausserhalb.
- Das Tag-Formular der Auswahlleiste war 16px breiter als sein Popover und
  wurde von dessen overflow-hidden beschnitten – auf jeder Breite.
- Die Heatmap scrollt wieder seitlich, statt sich zu stauchen.
- Die Kennzahlen stehen zweispaltig; einspaltig fuellten die fuenf Kacheln
  den ganzen ersten Bildschirm, bevor das erste Projekt kam.
- viewportFit und die safe-area-Abstaende, damit im installierten Betrieb
  nichts unter Statusleiste und Home-Anzeige verschwindet.
- Die beiden Verweise in der Kopfleiste und "Neues Projekt" tragen ein
  aria-label. Unterhalb von sm sind sie reine Symbole und hiessen fuer einen
  Screenreader gar nichts; axe-core meldete das als link-name.

axe-core laeuft auf 375px ueber alle vier Ansichten, den Dialog und die
Projektseite jetzt ohne Beanstandung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:58:27 +02:00
florian.wildeandClaude Opus 5 86f1fbeb50 fix(scripts): Ausfuehrbarkeitsbit im Git setzen
CI / pruefen (push) Has been cancelled
Die Shell-Skripte lagen als 100644 im Index. Nach einem frischen Clone auf
dem Server scheiterte damit jedes "./scripts/setup.sh" und "./scripts/update.sh"
an fehlender Berechtigung; wer sich mit chmod +x behalf, hatte danach ein
schmutziges Arbeitsverzeichnis, und genau daran brach update.sh ab.

Unter Windows faellt das nicht auf, weil core.filemode dort auf false steht --
das Bit muss deshalb mit "git update-index --chmod=+x" gesetzt werden. Auch
docker/entrypoint.sh bekommt es; der Dockerfile-Aufruf chmod +x bleibt als
zweiter Riegel bestehen.

Ausserdem sagt update.sh jetzt, welche Dateien geaendert sind, statt nur zu
melden, dass etwas nicht sauber ist. Wer die Meldung liest, soll nicht erst
"git status" nachschlagen muessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:13:56 +02:00
florian.wildeandClaude Opus 5 fc54c73791 fix(docker): die Datenbank veroeffentlicht ueberhaupt keinen Host-Port mehr
CI / pruefen (push) Has been cancelled
Der letzte Anlauf hat das Problem nicht geloest. Die Freigabe stand weiter in
der Basisdatei, nur mit anderem Vorgabewert -- und Vorgabewerte greifen nicht,
wo die Variable schon gesetzt ist: in jeder .env, die aus der bisherigen
.env.example entstanden ist, steht POSTGRES_PORT="5432". Postgres band also
unveraendert auf 0.0.0.0 und kollidierte mit anderen Stacks auf demselben
Host; der Container kam gar nicht erst hoch. Das Overlay half nicht, weil es
erst greift, wenn man es dazunimmt.

Jetzt hat der db-Dienst keinen ports-Block mehr. Gebraucht wird er nur, wenn
die Anwendung ausserhalb des Containers laeuft, und dafuer gibt es
docker-compose.dev.yml (npm run docker:db). Damit ist der sichere Fall die
Vorgabe und der Sonderfall die Ausnahme, nicht umgekehrt.

Der App-Port bindet aus demselben Grund auf 127.0.0.1: Docker traegt seine
Freigaben direkt in iptables ein und umgeht dabei ufw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:46:24 +02:00
florian.wildeandClaude Opus 5 d311fe48c0 fix(docker): keine Ports auf 0.0.0.0 und ein Overlay ohne Freigaben
CI / pruefen (push) Has been cancelled
Der Datenbankport wurde in beiden Betriebsarten auf 0.0.0.0 gelegt, obwohl
ihn nur die Entwicklung braucht: im Container spricht die Anwendung "db:5432"
ueber das Compose-Netz an, und backup.sh geht ueber docker exec. Auf einem
Server lauschte Postgres damit im Netz, geschuetzt allein durch das Passwort
aus der .env. Die Anleitung riet zwar zu 127.0.0.1, aber als Handgriff statt
als Voreinstellung -- die falsche Richtung. Datenbank und Adminer binden
jetzt von Haus aus auf 127.0.0.1.

Liegen mehrere Stacks auf einem Host und regelt ein gemeinsamer Reverse Proxy
den Zugang, braucht Nebula gar keinen Host-Port. docker-compose.server.yml
nimmt beide Freigaben zurueck und haengt die Anwendung in ein geteiltes Netz.
Das braucht !reset: Compose fuehrt "ports" beim Zusammenfuehren anhaengend
statt ersetzend zusammen, eine leere Liste im Override wuerde also nichts
loeschen.

update.sh las die Adresse aus APP_PORT und haette in diesem Modus einen Port
gemeldet, den es nicht gibt. Es fragt jetzt Docker und sagt sonst, dass nur
das interne Netz erreichbar ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:51 +02:00
florian.wildeandClaude Opus 5 6808d4d34a fix(scripts): Einrichtung und Update ohne Node
CI / pruefen (push) Has been cancelled
Ein reiner Docker-Host hat kein Node installiert. Die Anleitung verlangte
trotzdem "node scripts/setup.mjs", und update.sh las die Versionsnummer mit
"node -p" - beides waere dort gescheitert.

scripts/setup.sh macht dasselbe wie die .mjs-Fassung in reinem POSIX-Shell:
.env aus der Vorlage anlegen, ein APP_SECRET wuerfeln (openssl, sonst
/dev/urandom) und die Rechte auf 600 setzen. Ersetzt wird zeilenweise statt
mit einem sed-Ausdruck, weil base64 Schraegstriche enthaelt. update.sh liest
die Version jetzt selbst aus der package.json.

Dabei fielen zwei Fehler auf, die mein eigener Rat verursacht hat. Die
Anleitung empfiehlt APP_PORT="127.0.0.1:3000", damit die Anwendung nur hinter
dem Reverse Proxy erreichbar ist - die Gesundheitspruefung baute daraus aber
"http://127.0.0.1:127.0.0.1:3000". Und curl ist auf einem Docker-Host nicht
garantiert. Gefragt wird deshalb jetzt der Container selbst ueber seinen
HEALTHCHECK; curl liefert nur noch die Antwort zum Anzeigen, wenn es da ist.

Am Ende nannte das Skript den Sicherungspfad mit Anfuehrungszeichen - so
liess er sich nicht kopieren.

Nachgewiesen: dreimal gegen die laufende Instanz durchgelaufen, jedes Mal
mit Sicherung, Bau, Neustart und gesunder Antwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:26:46 +02:00
florian.wildeandClaude Opus 5 47245152a2 docs: Anleitung fuer den Serverbetrieb und Update-Skript
CI / pruefen (push) Has been cancelled
DEPLOYMENT.md fuehrt Schritt fuer Schritt vom leeren Server zur Instanz
hinter TLS: Docker, Konfiguration, Reverse Proxy mit Caddy oder nginx,
Firewall, Sicherungen per Cron. Dazu, was an Haertung schon eingebaut ist -
und was der Betreiber selbst beisteuern muss.

scripts/update.sh macht die Schritte in der richtigen Reihenfolge: pruefen,
holen, sichern, bauen, starten, Gesundheit pruefen. Erst bauen und dann
umschalten, damit ein gescheiterter Bau die laufende Fassung nicht
mitnimmt; die Sicherung liegt vor jeder Schemaaenderung. Antwortet
/api/health nach einer Minute nicht, zeigt es die letzten Logzeilen und
nennt den Weg zurueck.

Es weigert sich zu laufen, solange APP_SECRET leer oder noch der
Entwicklungswert ist - mit diesem Wert liegen die Git-Tokens faktisch offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 d7176339b8 test: Bereinigung des Markdowns festhalten, Ratenbegrenzung nachziehen
Elf Tests halten fest, was nicht durchkommen darf: script-Elemente,
Ereignis-Attribute aus rohem HTML, javascript: als Link und als Bildquelle,
data:text/html. Dazu die Aufloesung relativer Verweise und der Fall
"Bild im Link", der kein a im a erzeugen darf.

Gerendert wird serverseitig - fuer die Frage nach dem erzeugten HTML
braucht es kein DOM. Damit .tsx ueberhaupt laeuft, muss Vitest das JSX
selbst uebersetzen; die tsconfig setzt jsx auf "preserve", weil sonst Next
zustaendig ist. Vite nimmt inzwischen oxc statt esbuild, die esbuild-Option
wuerde stillschweigend ignoriert.

Umsortieren im Brett und das Anlegen von Vorlagen hatten als einzige
schreibende Endpunkte keine Ratenbegrenzung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 df94646371 fix(security): Bildoptimierer abschalten
/_next/image war ohne Anmeldung erreichbar und antwortete mit 200. Die
Middleware nimmt den Pfad aus, weil dort sonst nur Rechenzeit verbraucht
wuerde - gedacht war das fuer ausgelieferte Dateien, nicht fuer einen
Endpunkt, der Bilder verarbeitet.

Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
regelmaessig Schwachstellen gibt; derzeit sind vier offen (CVE-2026-33327,
-33328, -35590, -35591). Ausnutzbar waere das hier nur mit einer Datei, die
schon auf dem Server liegt - hochladen kann man nichts. Aber Nebula benutzt
next/image ohnehin nirgends: eine Angriffsflaeche ohne Gegenwert.

Der Optimierer ist deshalb abgeschaltet, und der Pfad ist nicht mehr von der
Zugangsschranke ausgenommen. Ein Test haelt fest, dass er ohne Anmeldung
nicht mehr mit 200 antwortet.

Die Advisories zu postcss und sharp bleiben in npm audit stehen: Next pinnt
beide fest, auch in 16.2.12 - ein Versionssprung wuerde daran nichts
aendern. postcss laeuft nur zur Bauzeit ueber eigenes CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 27f51b6be8 fix(auth): Einmalkennwoerter nicht zweimal annehmen
Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

Im Browser gegen vercel/swr geprueft, dazu ein erfundenes README fuer die
relativen Links, die swr nicht hergibt: aufgeloeste Pfade, unveraenderte
absolute Links, Anker bleiben Anker, kein img im Dokument und kein a im a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 e97bd43b23 feat(ui): README auf der Projektseite anzeigen
Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00
florian.wildeandClaude Opus 5 01884f4747 test: wiederkehrende Aufgaben abdecken, Doku, Version 1.3.0
CI / pruefen (push) Has been cancelled
Sieben Tests fuer die Terminlogik: Rechnen vom Faelligkeitsdatum,
Vorruecken bei langem Liegenbleiben, der Monatsuebergang ueber kurze
Monate, und dass die Schleife auch bei einem Datum von 1970 endet.

Dazu ein Ende-zu-Ende-Test ueber beide Wege nach ERLEDIGT und den Nachweis,
dass ein erneutes Speichern keine zweite Fassung erzeugt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 04a07bd36c fix(ui): Kontrast gedaempfter Texte auf WCAG AA bringen
Eine Durchsicht mit axe-core ueber zehn Seitenzustaende - Dialoge einzeln,
sie stehen geschlossen nicht im DOM - fand 26 Verstoesse, alle derselben
Art: slate-500 kommt auf dem sehr dunklen Untergrund nur auf 3,8 zu 1,
slate-600 sogar auf 2,5. Verlangt sind 4,5 fuer normalen Text, und beide
Stufen werden reichlich fuer Hinweise und Nebenangaben benutzt.

Statt an hundert Stellen die Klasse zu tauschen, sind die beiden Stufen im
Theme aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar, der Rest des
Entwurfs unangetastet. Geprueft gegen alle drei vorkommenden Untergruende;
der schlechteste Wert zaehlt.

Damit stimmt auch, was FEATURES.md ueber Barrierefreiheit behauptet - bisher
stand die Behauptung ungeprueft da.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:40 +02:00
florian.wildeandClaude Opus 5 a3093dc68a feat(tasks): wiederkehrende Aufgaben
Taeglich, woechentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe
nach ERLEDIGT, entsteht die naechste von selbst. Die erledigte bleibt
stehen - sonst ginge die Historie verloren.

Gerechnet wird vom Faelligkeitsdatum, nicht vom Abhaken: eine woechentliche
Aufgabe bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch
erledigt wurde. Lange Liegengebliebenes rutscht so weit vor, bis der Termin
in der Zukunft liegt; eine Aufgabe, die schon bei ihrer Entstehung
ueberfaellig ist, hilft niemandem.

Beim Monatsintervall bleibt der Tag im Monat erhalten. setMonth allein
rollt ueber: der 31. Januar wuerde zum 3. Maerz statt zum 28. Februar.

Ausgeloest wird an beiden Stellen, an denen eine Aufgabe erledigt werden
kann - im Dialog und durch Ziehen ins Brett. Nur der Uebergang zaehlt: eine
bereits erledigte Aufgabe erneut zu speichern erzeugt keine zweite Fassung.

Bewusst grob gehalten. Feinere Regeln wie "jeden zweiten Dienstag im Monat"
braucht ein Projektdashboard nicht, und sie waeren ohne Kalenderansicht
kaum nachvollziehbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:20:39 +02:00
florian.wildeandClaude Opus 5 52cfb075e3 test: Heatmap und Vorlagen abdecken, Doku, Version 1.2.0
CI / pruefen (push) Has been cancelled
Elf Tests fuer die Seitenlogik des Kalenders: Deckel, Abbruch bei
unvollstaendiger Seite, Commits mit Datum in der Zukunft, und dass der
Zeitraum beim aeltesten bekannten Commit beginnt statt vor einem Jahr.

Dazu ein Ende-zu-Ende-Test fuer Vorlagen: sichern, anwenden, Datentrennung
gegen ein zweites Konto, und dass die Vorlage das Loeschen ihres
Ursprungsprojekts ueberlebt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 57c50cfb7f feat(projects): Projektvorlagen
Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage
sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim
Anlegen steht sie oben im Dialog zur Auswahl.

Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben
sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste
waere geraten.

Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage
beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories
nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt,
waere eine Falle.

Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie
vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen.
Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich
nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht
mitgeschleppt bekommen.

templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus
einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:38 +02:00
florian.wildeandClaude Opus 5 ca6181b58f feat(git): Commit-Heatmap ueber ein Jahr
Ein Feld je Tag auf der Projektseite, Wochen als Spalten, mit Farbskala und
Datum im Tooltip.

Woher die Zahlen kommen, unterscheidet sich je Anbieter. GitHub fuehrt die
Statistik selbst und liefert 52 Wochen mit Tageswerten in einem einzigen
Aufruf. GitLab und Gitea kennen nichts Vergleichbares; dort werden Commits
seitenweise geholt, gedeckelt auf fuenf Seiten - genug fuer die allermeisten
Repositories und wenig genug, um kein fremdes Kontingent zu sprengen.

Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen. Reichen
die Seiten nur ein paar Monate zurueck, faengt die Heatmap dort an und sagt
es dazu. Ein leeres Feld wuerde sonst behaupten, an dem Tag sei nicht
gearbeitet worden, obwohl darueber nichts bekannt ist - genau diesen Fehler
hatte die Verlaufslinie schon einmal.

Geholt wird hoechstens einmal am Tag und getrennt vom uebrigen Abgleich: an
einem Jahresueberblick aendert sich von Stunde zu Stunde nichts Sichtbares,
der Abgleich laeuft aber im Zweifel stuendlich.

Der Kalender benutzt getJson statt getJsonSafe. Waere ein Rate-Limit als
"keine Daten" durchgegangen, haette der gesetzte Zeitstempel die Heatmap
einen ganzen Tag lang unterdrueckt. Jetzt bleibt er bei einem Fehlschlag
ungesetzt, und der naechste Abgleich versucht es erneut - das deckt auch den
Fall ab, dass GitHub die Statistik gerade erst berechnet und mit 202
antwortet.

Nachgewiesen an vercel/swr: 49 Commits an 33 Tagen ueber ein volles Jahr aus
der Statistik, und mit kurzer Historie 90 statt 365 Felder samt Hinweis auf
den Deckel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:02:19 +02:00
florian.wildeandClaude Opus 5 fce0a90cc3 chore: prisma.config.ts, Tests und Doku, Version 1.1.0
CI / pruefen (push) Has been cancelled
Prisma liest seine Einstellungen aus prisma.config.ts statt aus der
package.json; die Warnung vor Prisma 7 ist damit weg. Der dotenv-Aufruf
darin ist nicht optional: sobald die Datei existiert, laedt Prisma die .env
nicht mehr von selbst, und migrate deploy staende ohne DATABASE_URL da.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:30:17 +02:00
134 changed files with 11762 additions and 419 deletions
+28 -7
View File
@@ -23,11 +23,13 @@ APP_URL="http://localhost:3000"
# Port innerhalb des Containers bzw. im Standalone-Betrieb # Port innerhalb des Containers bzw. im Standalone-Betrieb
PORT="3000" PORT="3000"
# Host-Port-Mapping fuer den App-Container (--profile full) # Host-Port-Mapping fuer den App-Container (--profile full). Nur auf dem
APP_PORT="3000" # Loopback – nach draussen geht es ueber einen Reverse Proxy. Soll die
# Instanz im LAN erreichbar sein, hier "3000" eintragen.
APP_PORT="127.0.0.1:3000"
# ── Datenbank ──────────────────────────────────────────────── # ── Datenbank ────────────────────────────────────────────────
# Standalone-Betrieb: App laeuft lokal, Postgres via "docker compose up -d db" # Standalone-Betrieb: App laeuft lokal, Postgres via "npm run docker:db"
# oder als nativ installierter Dienst. # oder als nativ installierter Dienst.
DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public" DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
@@ -38,10 +40,24 @@ DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public"
POSTGRES_USER="nebula" POSTGRES_USER="nebula"
POSTGRES_PASSWORD="nebula" POSTGRES_PASSWORD="nebula"
POSTGRES_DB="nebula" POSTGRES_DB="nebula"
# Host-Port, unter dem die Datenbank erreichbar ist # Host-Port der Datenbank. Wirkt NUR zusammen mit docker-compose.dev.yml
POSTGRES_PORT="5432" # (npm run docker:db) – die Basisdatei veroeffentlicht keinen DB-Port, weil
# die Anwendung im Container "db:5432" anspricht und nichts davon nach
# aussen muss. Auf einem Server bleibt dieser Wert also folgenlos.
POSTGRES_PORT="127.0.0.1:5432"
# Host-Port fuer Adminer (--profile tools) # Host-Port fuer Adminer (--profile tools)
ADMINER_PORT="8080" ADMINER_PORT="127.0.0.1:8080"
# ── Betrieb ohne veroeffentlichte Ports ──────────────────────
# Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
# Reverse Proxy den Zugang, veroeffentlicht Nebula gar keinen Port. Dann diese
# beiden Zeilen aktivieren – danach genuegt ueberall "docker compose up -d":
# COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
# COMPOSE_PROFILES="full"
#
# Name des geteilten Netzes und der Alias, unter dem der Proxy Nebula findet.
# PROXY_NETWORK="proxy"
# PROXY_ALIAS="nebula"
# ── Sicherheit ─────────────────────────────────────────────── # ── Sicherheit ───────────────────────────────────────────────
# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM). # Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM).
@@ -53,9 +69,14 @@ ADMINER_PORT="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.
+65 -5
View File
@@ -4,12 +4,13 @@ Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**. Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) · Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
Betrieb: [README.md](README.md) Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
## Womit man arbeitet ## Womit man arbeitet
```bash ```bash
docker compose up -d db # Postgres, muss laufen npm run docker:db # Postgres, muss laufen (mit dem dev-Overlay,
# sonst hat die DB keinen Host-Port)
npm run dev # Entwicklung npm run dev # Entwicklung
npm test # Unit-Tests (Vitest, ohne DB) npm test # Unit-Tests (Vitest, ohne DB)
npx tsc --noEmit # Typprüfung npx tsc --noEmit # Typprüfung
@@ -63,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
@@ -82,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.
@@ -102,6 +143,25 @@ 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
Entwicklungsabhängigkeit bereit; ein Skript hängt es per `addScriptTag` in
die laufende Seite und ruft `axe.run` mit den WCAG-Tags auf. Jeder
Dialogzustand ist eine eigene Prüfung — geschlossene Dialoge stehen nicht im
DOM. Der Kontrast gedämpfter Texte ist der Punkt, der hier schon einmal
gerissen wurde; die aufgehellten Stufen stehen in `globals.css` im `@theme`.
Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`), Wer den Standalone-Bau von Hand startet (`node .next/standalone/server.js`),
muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein muss vorher `.next/static` und `public` danebenkopieren — sonst lädt kein
JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden JavaScript, die Seite bleibt serverseitig gerendert, und Formulare senden
+453
View File
@@ -0,0 +1,453 @@
# Nebula auf einem Server betreiben
Schritt für Schritt: von der leeren Maschine bis zur laufenden Instanz hinter
TLS — und wie Updates danach ablaufen.
Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
[README.md](README.md). Hier geht es um den Dauerbetrieb.
---
## Was du brauchst
| | |
| --- | --- |
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
| Sonst | `git`, `curl`, eine POSIX-Shell — **kein Node, kein npm** |
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
| Domain | Eine (Unter-)Domain, die auf den Server zeigt |
| TLS | Pflicht, wenn du Passkeys willst — siehe Schritt 6 |
> **Warum TLS Pflicht ist:** Passkeys verlangen einen sicheren Kontext.
> Über `http://` an einer LAN-IP funktionieren sie nicht. Passwort und
> Zwei-Faktor gehen auch ohne, aber dann läuft dein Passwort im Klartext
> übers Netz — betreibe Nebula nicht ohne TLS.
---
## 1 · Docker installieren
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker # oder ab- und wieder anmelden
docker --version && docker compose version
```
## 2 · Nebula holen
```bash
sudo mkdir -p /opt/nebula && sudo chown "$USER" /opt/nebula
git clone https://git.example.com/dein-name/nebula.git /opt/nebula
cd /opt/nebula
```
## 3 · Konfigurieren
```bash
./scripts/setup.sh # legt .env an, würfelt ein APP_SECRET, setzt Rechte 600
```
Das Skript ist reines POSIX-Shell und braucht **kein Node** — auf einem
reinen Docker-Host ist keins installiert. Es nimmt `openssl`, falls
vorhanden, sonst `/dev/urandom`.
Ganz von Hand geht auch:
```bash
cp .env.example .env
chmod 600 .env
openssl rand -base64 32 # den Wert als APP_SECRET eintragen
```
Dann die `.env` öffnen und mindestens diese drei Werte anpassen:
```env
# Die Adresse, unter der du Nebula im Browser aufrufst.
# Bestimmt die WebAuthn-rpID — passt sie nicht, scheitern Passkeys.
APP_URL="https://nebula.deine-domain.de"
# Verschlüsselt die Git-Tokens. Lang, zufällig, niemals teilen.
APP_SECRET="…dein-langer-zufallswert…"
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
```
Die Ports müssen nicht angefasst werden: `APP_PORT` steht schon auf
`127.0.0.1:3000`, und die Datenbank veröffentlicht gar nichts.
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
> bleiben unberührt.
>
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
> sie an die rpID gebunden sind. Passwörter funktionieren weiter.
### Welche Ports überhaupt hinausgehen
Nach einer regulären Installation ist es **genau einer**:
| Container | Host-Port | Wann |
| --- | --- | --- |
| `nebula-app` | `APP_PORT`, Vorgabe `127.0.0.1:3000` | `--profile full` |
| `nebula-db` | — | **nie** |
| `nebula-adminer` | `ADMINER_PORT`, Vorgabe `127.0.0.1:8080` | nur `--profile tools` |
**Die Datenbank veröffentlicht nichts.** Im Vollbetrieb spricht die Anwendung
Postgres über das interne Docker-Netz als `db:5432` an — die `DATABASE_URL`
setzt Compose selbst. Auch `scripts/backup.sh` und `restore.sh` gehen über
`docker exec`, nicht über den Host. Ein Host-Port wäre nur eine zusätzliche
Angriffsfläche und würde mit anderen Postgres-Containern auf demselben Host
um den Port streiten.
Wer die Datenbank beim Entwickeln doch vom Host aus braucht — `npm run dev`
läuft ja außerhalb des Containers — nimmt `docker-compose.dev.yml` dazu
(`npm run docker:db`). Auf einem Server hat diese Datei nichts zu suchen.
Auch der Port der Anwendung liegt auf `127.0.0.1`: nach draußen geht es über
den Reverse Proxy aus Schritt 6.
### Variante: gar keinen Port veröffentlichen
Liegen mehrere Stacks auf demselben Docker-Host und regelt ein gemeinsamer
Reverse Proxy den Zugang, braucht Nebula überhaupt keinen Host-Port. Dafür
gibt es **`docker-compose.server.yml`**: es nimmt die letzte verbliebene
Freigabe zurück und hängt die Anwendung in ein geteiltes Netz.
```bash
docker network create proxy # falls der Proxy-Stack es nicht schon anlegt
```
```env
# .env
COMPOSE_FILE="docker-compose.yml:docker-compose.server.yml"
COMPOSE_PROFILES="full"
PROXY_NETWORK="proxy" # so heißt das Netz
PROXY_ALIAS="nebula" # so findet der Proxy die Anwendung
```
Compose liest beides aus der `.env`. Danach genügt überall — auch in
`scripts/update.sh` — ein schlichtes `docker compose up -d`. Im Proxy zeigt
das Ziel dann nicht mehr auf `127.0.0.1:3000`, sondern auf **`nebula:3000`**;
Schritt 6 zeigt beide Schreibweisen.
> Ohne die Datei würde ein Override die Portlisten **zusammenhängen** statt
> sie zu ersetzen — Compose führt `ports` anfügend zusammen. Das `!reset`
> darin ist genau dafür da und braucht Compose 2.24 oder neuer.
>
> Das Trennzeichen in `COMPOSE_FILE` ist unter Linux der Doppelpunkt, unter
> Windows das Semikolon.
## 4 · Starten
```bash
docker compose --profile full up -d --build
```
Der erste Bau dauert ein paar Minuten. Der Container spielt beim Start
selbst die Migrationen ein und wartet dabei auf die Datenbank.
```bash
docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das Lesen
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
```
Ohne veröffentlichten Port geht die Frage an den Container statt an den Host
— das Abbild bringt einen `HEALTHCHECK` mit:
```bash
docker inspect --format '{{.State.Health.Status}}' nebula-app # healthy
```
## 5 · Ersten Zugang anlegen
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
vorübergehend über einen SSH-Tunnel:
```bash
ssh -L 3000:127.0.0.1:3000 benutzer@server
# dann lokal http://localhost:3000/setup öffnen
```
Dort legst du das Administratorkonto an und wählst die Betriebsart.
**Danach ist `/setup` dauerhaft gesperrt.**
Anschließend gleich unter *Mein Konto*:
1. **Passkey hinzufügen** — geht erst, wenn TLS steht
2. **Zwei-Faktor einrichten** und die Wiederherstellungscodes wegschreiben
## 6 · Reverse Proxy mit TLS
Nebula bringt keinen eigenen TLS-Abschluss mit. Beispiel mit **Caddy**, das
Zertifikate von selbst besorgt und erneuert:
```caddyfile
# /etc/caddy/Caddyfile
nebula.deine-domain.de {
reverse_proxy 127.0.0.1:3000
}
```
```bash
sudo systemctl reload caddy
```
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
Let's Encrypt und leitet `http` auf `https` um.
Läuft der Proxy selbst als Container im geteilten Netz (die Variante ohne
veröffentlichte Ports), lautet das Ziel stattdessen `nebula:3000` —
Docker löst den Alias auf:
```caddyfile
nebula.deine-domain.de {
reverse_proxy nebula:3000
}
```
<details>
<summary>Dasselbe mit nginx</summary>
```nginx
server {
listen 443 ssl http2;
server_name nebula.deine-domain.de;
ssl_certificate /etc/letsencrypt/live/nebula.deine-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nebula.deine-domain.de/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Zertifikat mit `certbot --nginx -d nebula.deine-domain.de`.
</details>
> Steht `APP_URL` auf `https://…`, setzt Nebula das `Secure`-Flag auf die
> Sitzungscookies. Läuft der Proxy auf `https`, die `APP_URL` aber noch auf
> `http`, funktioniert die Anmeldung — nur ohne dieses Flag. Also beides
> gleichziehen.
## 7 · Firewall
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
```
Port 3000 bleibt **zu**, 5432 wird ohnehin nie veröffentlicht. Die Firewall
ist hier der zweite Riegel, nicht der erste.
> Docker schreibt seine Portfreigaben direkt in `iptables` und geht dabei an
> `ufw` **vorbei**. Eine Freigabe auf `0.0.0.0` steht also im Netz, auch wenn
> `ufw` sie zu melden scheint. Genau deshalb wird hier an `127.0.0.1`
> gebunden, statt sich auf die Firewall zu verlassen.
## 8 · Sicherungen automatisieren
`scripts/backup.sh` legt einen `pg_dump` unter `backups/` ab. Täglich per
Cron:
```bash
crontab -e
```
```cron
# Jede Nacht um 3:15 sichern, 30 Tage aufheben
15 3 * * * cd /opt/nebula && ./scripts/backup.sh >> /var/log/nebula-backup.log 2>&1
30 3 * * * find /opt/nebula/backups -name '*.sql.gz' -mtime +30 -delete
```
Zurückspielen:
```bash
./scripts/restore.sh backups/nebula-2026-07-27-03-15-00.sql.gz
```
> Die Sicherungen liegen auf **demselben Server**. Das hilft gegen einen
> verpatzten Import, nicht gegen einen Plattenausfall. Zieh sie regelmäßig
> woandershin — `rsync`, `restic`, was du ohnehin nutzt.
---
## 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
Reihenfolge und bricht ab, sobald ein Schritt scheitert:
```bash
cd /opt/nebula
./scripts/update.sh
```
Was es tut:
| Schritt | |
| --- | --- |
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
| | *(alles ohne Node — das Skript liest die Version selbst aus der `package.json`)* |
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
| **6 Prüfen** | Bis zu zwei Minuten warten, bis der Container sich gesund meldet |
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
nennt den Weg zurück.
```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.9.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
```
### Wenn doch etwas schiefgeht
```bash
# Zurück auf den vorigen Stand (die Kennung nennt das Skript beim Abbruch)
git checkout <kennung>
./scripts/update.sh --no-pull --yes
# Nur wenn eine Migration die Daten beschädigt hat:
./scripts/restore.sh backups/<die-sicherung-von-vorhin>.sql.gz
```
> **Migrationen werden nicht zurückgerollt.** Eine Migration, die eine Spalte
> entfernt, ist nicht umkehrbar — deshalb sichert das Skript vorher. Steht
> ein Update mit Datenmigration an, lies vorher den Änderungsverlauf
> (Versionsnummer in der Fußzeile).
---
## Betrieb
### Zustand ansehen
```bash
docker compose --profile full ps
docker compose --profile full logs -f app
curl -s localhost:3000/api/health
```
### Neu starten
```bash
docker compose --profile full restart app
```
### Speicher- und Plattenverbrauch
```bash
docker stats --no-stream nebula-app nebula-db
docker system df
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
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
Automatischer Abgleich*: Abstand, Repositories je Durchlauf, ob fertige
Projekte ausgelassen werden. Der Zeitgeber läuft im Serverprozess selbst —
kein zusätzlicher Container, kein Cron.
Wer lieber von außen taktet, lässt ihn aus und ruft
`POST /api/admin/sync/run` auf.
---
## Härtung, die schon eingebaut ist
Damit du weißt, worum du dich **nicht** kümmern musst:
- Der Container läuft als **unprivilegierter Benutzer** (`nextjs`, UID 1001),
nicht als root.
- **Content-Security-Policy** mit Nonce, `strict-dynamic` und ohne
`unsafe-inline` für Skripte.
- **CSRF-Prüfung** über `Sec-Fetch-Site`/`Origin` bei jedem schreibenden
Zugriff.
- **SSRF-Schutz** bei allen ausgehenden Aufrufen: jede Adresse wird
aufgelöst und gegen Loopback, Link-Local und Metadaten-Endpunkte geprüft,
Weiterleitungen eingeschlossen.
- **Ratenbegrenzung** auf Anmeldung, ausgehende Abrufe und schreibende
Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
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,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
### Was du beisteuern musst
- **TLS** über den Reverse Proxy
- **`APP_SECRET`** lang und zufällig
- **Datenbankport** nicht nach außen öffnen
- **Sicherungen** wegschaffen
- **Systemupdates**: `unattended-upgrades` für das Betriebssystem,
`./scripts/update.sh` für Nebula
---
## Mehrbenutzerbetrieb
Nebula kann beides. Im Einzelbetrieb gibt es genau ein Konto und keine
Registrierung. Umschalten unter *Admin-Einstellungen*.
Wichtig zu wissen: **Projekte sind zwischen Konten hart getrennt.** Auch ein
Administrator sieht fremde Projekte nicht — er verwaltet Konten, keine
Inhalte. Wer gemeinsam an einem Projekt arbeiten will, ist mit Nebula falsch
beraten; das ist [bewusst nicht](ROADMAP.md) vorgesehen.
Die Selbstregistrierung ist standardmäßig aus. Lass sie aus, wenn die
Instanz aus dem Internet erreichbar ist und du nicht willst, dass sich
Fremde Konten anlegen.
+400 -16
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.
@@ -109,19 +120,42 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
## 2. Dashboard ## 2. Dashboard
### Schnellerfassung
Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig —
als **Idee** entsteht ein Projekt im Status `IDEA`, als **Aufgabe** eine Aufgabe im
gewählten Projekt (`TODO`). Alles Weitere setzen die Vorgaben des Schemas.
Der Dialog **bleibt nach dem Anlegen offen** und leert nur das Feld. Wer eine Idee
notiert, hat meistens gleich die nächste; ihn jedes Mal neu zu öffnen wäre genau die
Hürde, die hier wegsoll. Angelegtes wird darunter mit Link bestätigt. Das zuletzt
gewählte Projekt wird gemerkt.
Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts —
dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert.
### Überblick
- **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive - **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive
Entwicklungen, fertige Projekte, verknüpfte Repositories. Entwicklungen, fertige Projekte, verknüpfte Repositories. Auf dem Telefon zweispaltig,
damit die Projekte nicht erst nach dem zweiten Wischen beginnen.
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad. - **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
Favoriten bleiben stets oben. Favoriten bleiben stets oben.
- **Drei Ansichten**: freies Raster, nach Status gruppierte Abschnitte oder **Kanban**. - **Vier Ansichten**: freies Raster, **kompakte Liste**, nach Status gruppierte Abschnitte
- **Volltextsuche über Notizen.** Die Suche im Board arbeitet auf den geladenen Projekten oder **Kanban**.
(Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen liegen dort nicht vor — sie - **Volltextsuche über Notizen und Aufgaben.** Die Suche im Board arbeitet auf den
mitzuladen würde die Seite aufblähen. Stattdessen sucht Postgres serverseitig mit geladenen Projekten (Name, Kurzbeschreibung, Tags, Repo-Pfad). Notizen und Aufgaben
seiner deutschen Textsuche, entprellt ab zwei Zeichen. Treffer erscheinen als eigene liegen dort nicht vor — sie mitzuladen würde die Seite aufblähen. Stattdessen sucht
Liste mit hervorgehobener Fundstelle, die betroffenen Projekte werden zusätzlich Postgres serverseitig mit seiner deutschen Textsuche, entprellt ab zwei Zeichen.
eingeblendet. Deutsche Stammformen greifen: „Webhook" findet auch „Webhooks". Treffer erscheinen als zwei eigene Listen mit hervorgehobener Fundstelle, die
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.
@@ -138,6 +172,50 @@ Bedienung über Tastatur: Pfeile wählen, `Enter` öffnet, `Esc` schließt. Die
wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden wird beim ersten Öffnen geholt und dann behalten — sie bei jedem Tastendruck neu zu laden
wäre Verschwendung. wäre Verschwendung.
### Projektvorlagen
Ein Projekt, das sich bewährt hat, lässt sich im Projektkopf über **Als Vorlage sichern**
festhalten: Status, Priorität, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen eines
neuen Projekts steht die Vorlage oben im Dialog zur Auswahl.
Bewusst benutzereigen statt fest eingebaut — welche Tags und welche Startaufgaben sinnvoll
sind, weiß nur, wer damit arbeitet. Eine mitgelieferte Liste wäre geraten.
- **Erledigte Aufgaben kommen nicht mit.** Eine Vorlage beschreibt, was zu tun ist, nicht
was einmal getan wurde.
- **Repositories gehen nicht mit.** Eine Vorlage, die jedes neue Projekt an dasselbe
Repository hängt, wäre eine Falle.
- Die Stammdaten füllt der Dialog **sichtbar** in die Felder, damit sie vor dem Speichern
noch änderbar sind; bereits Getipptes wird nicht überschrieben. Notizen und Aufgaben legt
erst der Server an — im Dialog ließen sie sich nicht darstellen.
- Vorlagen überleben das Löschen ihres Ursprungsprojekts.
### Mehrfachauswahl
Ein Kästchen auf jeder Karte — es erscheint erst beim Überfahren, damit nicht auf jeder
Karte dauerhaft eines steht. Sobald etwas ausgewählt ist, fährt unten eine Leiste ein:
**Status setzen**, **Tags ergänzen oder entfernen**, **als Favorit markieren**, **löschen**.
Was der Filter ausblendet, fällt aus der Auswahl. Sonst träfe eine Massenänderung Projekte,
die man gar nicht mehr vor Augen hat. Fremde IDs fallen serverseitig still unter den Tisch.
### Liste
Eine Zeile je Projekt, alle gleich aufgebaut: Statusfarbe als Streifen, Name,
Fortschrittsbalken mit Prozentwert, danach Aufgaben, Notizen, Branch und Alter des
letzten Commits. Der Statuswechsler sitzt rechts, die ganze Zeile ist ein Link.
Gedacht ist die Ansicht für zwei Fälle:
- **Auf dem Telefon.** Eine Karte braucht rund 260 px Höhe und steht dort zwangsläufig
allein in der Zeile — fünf Projekte füllen zwei Bildschirme, bevor man überhaupt
etwas vergleichen kann. Eine Zeile kommt mit einem Viertel davon aus.
- **Beim Vergleichen.** Weil alle Zeilen gleich gebaut sind, liegen Name, Fortschritt
und Status untereinander. Genau das verliert das Raster, sobald es einspaltig wird.
Was nicht mehr in die Zeile passt, fällt der Reihe nach weg, statt sie zu sprengen:
Notizen ab `sm`, Branch ab `md`, der letzte Commit und die Priorität ab `lg`.
### Kanban ### Kanban
Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und Spalten je Status, Karten per Ziehen verschiebbar. Der Wechsel wird sofort angezeigt und
@@ -147,8 +225,15 @@ erst danach gespeichert; scheitert das Speichern, springt die Karte zurück.
normaler Link. normaler Link.
- Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren. - Das Ziehen startet erst nach ein paar Pixeln Bewegung, damit Klicks weiter funktionieren.
Auf Touchgeräten nach kurzem Halten. Auf Touchgeräten nach kurzem Halten.
- Die Reihenfolge innerhalb einer Spalte folgt der gewählten Sortierung. Frei sortieren - **Freie Reihenfolge innerhalb einer Spalte.** Die Position wird am Projekt gespeichert,
ginge nur mit einer gespeicherten Position — bewusst nicht gebaut. wie beim Aufgabenbrett. Deshalb ist die Sortierung im Kopf in dieser Ansicht abgeschaltet
— sie stehen zu lassen, ohne dass sie wirkt, wäre irreführend.
- Reines Umsortieren fasst das Änderungsdatum **nicht** an. Sonst schöbe ein Aufräumen im
Brett sämtliche Projekte in der Sortierung „Zuletzt geändert“ nach oben.
- Neue, importierte und per Status verschobene Projekte landen am Ende ihrer Spalte.
- **Ansicht, Sortierung und Archivschalter überdauern das Neuladen** (im Browser
gespeichert, nicht am Konto). Der Suchbegriff bleibt bewusst außen vor — eine Suche von
gestern beim Öffnen wiederherzustellen wäre eher verwirrend als hilfreich.
### Projektkarte auf einen Blick ### Projektkarte auf einen Blick
@@ -170,9 +255,30 @@ 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.
### Wiederkehrende Aufgaben
Täglich, wöchentlich, alle zwei Wochen oder monatlich. Wandert die Aufgabe nach
**Erledigt** — im Dialog oder durch Ziehen —, entsteht die nächste von selbst. Die
erledigte bleibt stehen, sonst ginge die Historie verloren.
- **Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken.** Eine wöchentliche Aufgabe
bleibt auf ihrem Montag, auch wenn sie einmal erst am Mittwoch erledigt wurde.
- Lange Liegengebliebenes rutscht so weit vor, bis der Termin in der Zukunft liegt — eine
Aufgabe, die schon bei ihrer Entstehung überfällig ist, hilft niemandem.
- Beim Monatsintervall bleibt der Tag im Monat erhalten. Der 31. Januar wird zum
28. Februar, nicht zum 3. März.
- Nur der **Übergang** nach Erledigt löst aus. Eine bereits erledigte Aufgabe erneut zu
speichern erzeugt keine zweite Fassung.
- Bewusst grob gehalten: feinere Regeln („jeden zweiten Dienstag im Monat") braucht ein
Projektdashboard nicht und wären ohne Kalenderansicht kaum nachvollziehbar.
### Aus Git-Issues übernehmen ### Aus Git-Issues übernehmen
Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel, Im Git-Panel, Reiter *Issues*: ein Klick macht aus den offenen Issues Aufgaben — Titel,
@@ -204,6 +310,94 @@ statt verschoben.
--- ---
## 2b. Aufgaben über alle Projekte
Die Seite `/tasks` sammelt jede Aufgabe aus jedem Projekt an einer Stelle. Erreichbar
über die Kopfleiste und die Schnellsuche.
**Geordnet wird nach Fälligkeit, nicht nach Spalte.** Projektübergreifend lautet die
Frage „was ist überfällig", nicht „was steht wo im Brett". Die Fächer:
| Fach | Inhalt |
| --- | --- |
| **Überfällig** | Termin liegt vor heute |
| **Heute** | Termin ist der heutige Kalendertag |
| **Diese Woche** | in den nächsten sieben Tagen |
| **Später** | alles danach |
| **Ohne Termin** | kein Fälligkeitsdatum gesetzt |
Gerechnet wird auf **Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf
Stunden und nicht nach Serverzeit. Eine heute um 9 Uhr fällige Aufgabe ist um 17 Uhr
nicht überfällig, sondern immer noch „heute" — sonst wanderte die halbe Liste im Lauf
des Tages nach oben. Und im Container läuft die Anwendung nach UTC; ohne feste Zeitzone
kippte die Einteilung abends um einen Tag. Das Fach bestimmt der **Server**, damit
Server-Render und Hydration nicht auseinanderlaufen.
- **Filter** nach Status (mit Zähler) und Projekt; Erledigtes ist ausgeblendet und
einzeln zuschaltbar.
- **Abhaken direkt aus der Liste.** Der Endpunkt erledigt dabei den Rest: Zeitpunkt,
abgeleiteter Projektfortschritt und — bei wiederkehrenden Aufgaben — die nächste
Fassung.
- Jede Zeile nennt ihr **Projekt** und verlinkt es; ohne das ist eine übergreifende
Liste nicht zu lesen. Stammt die Aufgabe aus einem Git-Issue, führt ein Symbol dorthin.
**Bewusst nicht sortierbar per Ziehen.** Die Position einer Aufgabe gilt innerhalb ihrer
Spalte in ihrem Projekt; hier wäre sie ohne Bedeutung. Eine Reihenfolge anzubieten, die
nirgends ankommt, wäre ein Versprechen, das die Seite nicht halten kann.
Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt
(`project: { ownerId }` als einzige und erste Bedingung).
---
## 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.
@@ -248,6 +442,29 @@ App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste. Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.0a README-Vorschau
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
READMEs sind eingeklappt, ein Knopf zeigt alles.
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
(Wurzelverzeichnis auflisten, dann die Datei holen).
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
Codeblock stehen bleibt.
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
liefen sie ins Leere.
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
Bereinigung wie Notizen (`rehype-sanitize`).
### 4.1 Unterstützte Systeme ### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit | | System | API | Authentifizierung | Besonderheit |
@@ -272,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 |
@@ -285,6 +536,7 @@ Firmen-Gitea nebeneinander.
| Tags | bis zu 25, mit SHA, Nachricht und Datum | | Tags | bis zu 25, mit SHA, Nachricht und Datum |
| Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve | | Commits | die letzten 15 zur Anzeige; geholt werden 100 für die Aktivitätskurve |
| **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite | | **Commit-Aktivität** | Balkenreihe der letzten Tage, auf Karte und Projektseite |
| **Commit-Heatmap** | Ein Feld je Tag über bis zu ein Jahr, auf der Projektseite |
| Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung | | Releases | bis zu 10, inklusive Pre-Release- und Draft-Kennzeichnung |
| Sprachen | Verteilung als Balken mit Prozentwerten | | Sprachen | Verteilung als Balken mit Prozentwerten |
@@ -311,9 +563,19 @@ bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung | | Einstellung | Bedeutung |
| --- | --- | | --- | --- |
| Abstand | 15 Minuten bis täglich | | Abstand | 15 Minuten bis täglich |
| Projekte je Durchlauf | 1–100, Voreinstellung 25 | | Repositories je Durchlauf | 1–100, Voreinstellung 25 |
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas | | Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
Gezählt und begrenzt wird je **Repository**, nicht je Projekt: sonst zöge ein Projekt mit
fünf Repos fünfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt.
Die Daten für die **Heatmap** kommen getrennt und höchstens einmal am Tag — an einem
Jahresüberblick ändert sich von Stunde zu Stunde nichts Sichtbares. Bei GitHub genügt dafür
ein Aufruf der Jahresstatistik; GitLab und Gitea kennen keine, dort werden bis zu fünf
Seiten Commits geholt. Reicht das nicht ein Jahr zurück, **beginnt die Heatmap beim
ältesten bekannten Commit** und sagt es dazu — leere Felder für Zeiträume zu zeichnen, über
die nichts bekannt ist, wäre eine Lüge.
Wie der Durchlauf arbeitet: Wie der Durchlauf arbeitet:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob - Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
@@ -343,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
@@ -457,10 +816,24 @@ letzten Zustellung stehen an der Karte.
- **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand, - **Animationen**: gestaffelt einblendende Karten, Hover-Lift mit Farbverlaufsrand,
animierte Fortschrittsbalken. animierte Fortschrittsbalken.
- **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten). - **Responsiv** von Handy bis Ultrawide (bis zu vier Kartenspalten).
- **Barrierearm**: durchgehende ARIA-Labels, Escape schließt Dialoge, sichtbarer - **Barrierefrei nach WCAG 2.1 AA**, und zwar geprüft statt behauptet: axe-core läuft über
Fokusring, `prefers-reduced-motion` schaltet Animationen ab. zehn Seitenzustände einschließlich der Dialoge und meldet keine Verstöße. Durchgehende
ARIA-Labels, Escape schließt Dialoge, sichtbarer Fokusring, `prefers-reduced-motion`
schaltet Animationen ab.
Bei der ersten Durchsicht fielen 26 Kontrastverstöße auf: gedämpfter Text erreichte auf
dem sehr dunklen Untergrund nur 3,8 statt 4,5 zu 1. Statt an hundert Stellen die Klasse
zu tauschen, sind die beiden gedämpften Stufen im Theme aufgehellt — der Abstand
zwischen ihnen bleibt sichtbar.
- **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone - **Deutschsprachig**, Datums- und Zahlenformate in `de-DE` mit fester Zeitzone
Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen. Europe/Berlin, damit Server- und Browser-Darstellung übereinstimmen.
- **Als App installierbar.** Ein Web-Manifest mit eigenen Symbolen — auf dem Handy
„Zum Startbildschirm hinzufügen“, danach läuft Nebula ohne Adressleiste. Die
maskierbare Fassung hat mehr Rand, weil Android Symbole je nach Gerät rund, eckig
oder als Tropfen zuschneidet.
- **Änderungsverlauf** hinter der Versionsnummer in der Fußzeile: was neu ist, was besser
wurde und welche Fehler behoben sind. Die Liste steht in `src/lib/changelog.ts` und ist
die einzige Quelle — ein Test hält sie und `package.json` zusammen, weil genau diese
beiden Angaben hier schon einmal auseinandergelaufen sind.
--- ---
@@ -479,7 +852,8 @@ letzten Zustellung stehen an der Karte.
| `AppSettings` | Betriebsart und Registrierung (eine Zeile) | | `AppSettings` | Betriebsart und Registrierung (eine Zeile) |
| `Project` | Projektstammdaten, Status, Fortschritt, Besitzer | | `Project` | Projektstammdaten, Status, Fortschritt, Besitzer |
| `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) | | `Repository` | Repositories eines Projekts inkl. Abgleichstand (n:1 zu Projekt) |
| `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels und Issue-Herkunft | | `Task` | Aufgaben mit Spalte, Position, Fälligkeit, Labels, Wiederholung und Issue-Herkunft |
| `ProjectTemplate` | Vorlagen je Konto: Stammdaten, Tags, Notizen und Aufgaben |
| `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt | | `Activity` | Verlauf je Projekt, neueste zuerst, auf 200 Einträge begrenzt |
| `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis | | `NotificationTarget` | Zustellwege je Konto, mit verschlüsseltem Geheimnis |
| `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) | | `Note` | Notizen (n:1 zu Projekt, Cascade-Delete) |
@@ -513,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
+176 -25
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
@@ -31,7 +37,8 @@ Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst
Access Tokens werden verschlüsselt abgelegt. Access Tokens werden verschlüsselt abgelegt.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**, Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. Für den Dauerbetrieb auf einem
Server — TLS, Firewall, Sicherungen und Updates — gibt es **[DEPLOYMENT.md](DEPLOYMENT.md)**.
--- ---
@@ -51,7 +58,7 @@ node scripts/setup.mjs # legt .env an und erzeugt ein zufälliges APP_SECRE
Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container: Die App läuft nativ auf dem Rechner, nur PostgreSQL kommt aus dem Container:
```bash ```bash
docker compose up -d db # Postgres 17 auf localhost:5432 npm run docker:db # Postgres 17 auf 127.0.0.1:5432
npm install npm install
npm run db:migrate # Schema anlegen npm run db:migrate # Schema anlegen
npm run db:seed # optional: drei Beispielprojekte npm run db:seed # optional: drei Beispielprojekte
@@ -117,12 +124,13 @@ 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 | `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` |
| `POSTGRES_PORT` | Host-Port der Datenbank | `5432` | | `POSTGRES_PORT` | Host-Port der Datenbank — wirkt nur mit `docker-compose.dev.yml`, sonst hat die DB keinen | `127.0.0.1:5432` |
| `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `8080` | | `ADMINER_PORT` | Host-Port von Adminer (`--profile tools`) | `127.0.0.1:8080` |
| `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` | | `GIT_API_TIMEOUT_MS` | Timeout für Git-API-Aufrufe | `15000` |
| `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` | | `GIT_ALLOW_LOOPBACK` | Git-Instanz auf demselben Rechner erlauben | `false` |
| `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` | | `GIT_BLOCK_PRIVATE` | Zugriff ins private LAN sperren | `false` |
@@ -165,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.
@@ -184,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
@@ -273,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
``` ```
@@ -306,7 +444,7 @@ nebula/
| `npm test` | Unit-Tests (Vitest, ohne Datenbank) | | `npm test` | Unit-Tests (Vitest, ohne Datenbank) |
| `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) | | `npm run test:e2e` | Ende-zu-Ende-Tests (Playwright, baut vorher) |
| `npm run test:all` | Beides nacheinander | | `npm run test:all` | Beides nacheinander |
| `npm run docker:db` | Nur die Datenbank starten | | `npm run docker:db` | Nur die Datenbank starten, mit Host-Port für `npm run dev` |
| `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen | | `npm run docker:up` / `docker:down` | Kompletten Stack starten / stoppen |
| `npm run docker:tools` | Adminer auf `http://localhost:8080` | | `npm run docker:tools` | Adminer auf `http://localhost:8080` |
@@ -341,21 +479,30 @@ ausschließlich Daten des angemeldeten Kontos.
| `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON | | `GET` | `/api/projects/export` | Alle eigenen Projekte als JSON |
| `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) | | `GET` | `/api/projects/:id/export` | Ein Projekt (`?format=md` für Markdown) |
| `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau | | `POST` | `/api/projects/import` | Import, mit `dryRun` für die Vorschau |
| `GET` `POST` | `/api/templates` | Projektvorlagen auflisten / anlegen |
| `PATCH` `DELETE` | `/api/templates/:id` | Vorlage ändern / löschen |
| `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 |
| `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen | | `GET` `PATCH` `DELETE` | `/api/projects/:id` | Projekt lesen / ändern / löschen |
| `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen | | `POST` | `/api/projects/:id/sync` | Alle Repositories des Projekts abgleichen |
| `PATCH` | `/api/projects/reorder` | Neue Reihenfolge im Brett |
| `POST` | `/api/projects/bulk` | Status, Tags oder Löschen für mehrere |
| `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) | | `GET` | `/api/projects/:id/activity` | Verlauf des Projekts (`?limit=`) |
| `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 |
@@ -410,7 +557,7 @@ Grenzen, die du kennen solltest:
| Symptom | Ursache / Lösung | | Symptom | Ursache / Lösung |
| --- | --- | | --- | --- |
| `Can't reach database server` | Läuft `docker compose up -d db`? Stimmt `DATABASE_URL`? | | `Can't reach database server` | Läuft `npm run docker:db`? Ein blosses `docker compose up -d db` startet die DB **ohne Host-Port** — dann ist sie von `npm run dev` aus nicht erreichbar. Stimmt `DATABASE_URL`? |
| `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` | | `The table ... does not exist` | Migrationen fehlen: `npm run db:migrate` |
| Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen | | Sync meldet `401` | Token abgelaufen oder falsche Scopes — unter Git-Verbindungen neu setzen |
| Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" | | Sync meldet `404` | Owner/Repo prüfen; bei GitLab gehört die volle Gruppenkette in „Owner" |
@@ -427,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 |
--- ---
+108 -30
View File
@@ -7,61 +7,120 @@ 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.1 Webhooks statt Polling `M` ### 1.1 Persönliche API-Token `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf Der letzte offene Teil aus v1.6.0 („unterwegs erfassen"). Derzeit geht die
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit API nur mit Sitzungscookie. Ein Token je Konto würde `curl`-Aufrufe und
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen Handy-Shortcuts erlauben — eine Idee aus dem Terminal anlegen, ohne den
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung. Browser zu öffnen. Nur der Hash in der Datenbank, wie bei den Sitzungen.
Bewusst noch nicht angefasst, weil sicherheitsrelevant: Geltungsbereich,
Widerruf und Ablauf wollen entschieden sein, nicht nebenbei gebaut.
### 1.2 Läuft es noch? `M`
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
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
greift dabei wie bei den Git-Aufrufen.
### 1.3 Aufgaben ↔ Issues: der Rest `S`
Beide Richtungen stehen — „als Issue anlegen" seit v1.7.0, der Rückweg über
Webhook (v1.7.0) und zusätzlich über den Abgleich (v1.9.0). Offen ist
Kleinkram:
- **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.
Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und
`BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den
Issue-Tracker mit Labels zu füllen, die dort niemand pflegt.
--- ---
## 2 · Danach ## 2 · Größere Brocken
### 2.1 Commit-Aktivität als Heatmap `S` ### 2.1 Passwort-Zurücksetzen per E-Mail `M`
Die Sparkline gibt es auf Karte und Projektseite. Eine Heatmap über ein Jahr
wäre die nächste Ausbaustufe — dafür braucht es allerdings mehr als eine
Seite Commits, also Paginierung oder die Statistik-Endpunkte.
### 2.2 Kanban mit freier Reihenfolge `M`
Heute folgt die Reihenfolge innerhalb einer Spalte der gewählten Sortierung.
Frei sortieren hieße, eine Position je Projekt zu speichern.
---
## 3 · Größere Brocken
### 3.1 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb. und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.4 Mehrsprachigkeit `L` ### 2.2 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst. Voraussetzung, falls du das Projekt veröffentlichen willst.
--- ---
## 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 ## 4 · Kleinigkeiten
| Idee | Aufwand | Nutzen | | Idee | Aufwand | Nutzen |
| --- | --- | --- | | --- | --- | --- |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste | | Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal | | Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.3 ab |
| Wiederkehrende Aufgaben | `M` | Etwa „jeden Montag Abhängigkeiten prüfen" | | Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel | | Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` | | Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7. Achtung: danach lädt Prisma die `.env` nicht mehr von selbst |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat | | Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
--- ---
@@ -73,8 +132,16 @@ Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und - **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten. sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Veraltete Abhängigkeiten prüfen.** Klingt verlockend, aber Renovate und
Dependabot machen das besser, und es wären viele Aufrufe an fremde
Registries — für eine Zahl, die im Repo selbst schon steht.
- **Anhänge an Notizen.** Bräuchte Dateispeicher und damit eine zweite Sorge
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.
@@ -101,6 +168,17 @@ Damit klar ist, was **nicht** kommt und warum:
| **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ | | **v0.8.0** Stillstehende Projekte melden, Schwelle im Adminbereich | ✓ |
| **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ | | **v0.9.0** Schnellsuche per `Strg+K`, Aufgaben aus Git-Issues | ✓ |
| **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ | | **v1.0.0** Aktivitätsverlauf je Projekt, Zwei-Faktor per TOTP, mehrere Repositories je Projekt | ✓ |
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
| **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
+23
View File
@@ -0,0 +1,23 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer die Entwicklung: macht die Datenbank auf dem Host erreichbar.
#
# Gebraucht wird das nur, wenn die Anwendung ausserhalb des Containers
# laeuft – "npm run dev" verbindet sich dann ueber localhost:5432. Im
# Vollbetrieb spricht die Anwendung "db:5432" im Docker-Netz an und braucht
# diese Datei nicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev
#
# Kuerzer: npm run docker:db
#
# Gebunden wird auf 127.0.0.1, nicht auf 0.0.0.0. Docker traegt Freigaben
# direkt in iptables ein und umgeht dabei ufw – eine offene Bindung stuende
# also auch bei aktiver Firewall im Netz.
# ─────────────────────────────────────────────────────────────
services:
db:
ports:
- "${POSTGRES_PORT:-127.0.0.1:5432}:5432"
+46
View File
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────────────────────
# Overlay fuer den Serverbetrieb: kein einziger Port geht zum Host.
#
# Gedacht fuer einen Docker-Host, auf dem mehrere Stacks liegen und ein
# gemeinsamer Reverse Proxy die einzige Tuer nach draussen ist. Die
# Datenbank veroeffentlicht ohnehin nichts; diese Datei nimmt zusaetzlich
# die Freigabe der Anwendung zurueck und haengt sie in ein geteiltes Netz,
# ueber das der Proxy sie erreicht.
#
# Aufruf:
# docker compose -f docker-compose.yml -f docker-compose.server.yml \
# --profile full up -d --build
#
# Bequemer ist, das dauerhaft in die .env zu schreiben:
# COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml
# COMPOSE_PROFILES=full
# Danach genuegt ueberall "docker compose up -d" – auch in update.sh.
# (Trennzeichen: Doppelpunkt unter Linux, Semikolon unter Windows.)
#
# Das Netz muss es vorher geben (der Proxy-Stack legt es ueblicherweise an):
# docker network create proxy
#
# Im Proxy zeigt das Ziel dann auf http://nebula:3000 bzw. nebula-app:3000.
# ─────────────────────────────────────────────────────────────
services:
app:
# !reset loescht die Portfreigabe aus der Basisdatei. Ohne dieses Tag
# wuerden die Listen zusammengefuegt statt ersetzt – Compose behandelt
# "ports" beim Zusammenfuehren als anzuhaengende Liste. Braucht
# Compose 2.24 oder neuer.
ports: !reset []
networks:
# Das stackeigene Netz: darueber spricht die Anwendung mit der Datenbank.
default:
# Das geteilte Netz zum Reverse Proxy. Der Alias ist der Name, unter dem
# der Proxy Nebula anspricht – kurz und eindeutig, denn "app" hiessen in
# einem geteilten Netz schnell mehrere Container.
proxy:
aliases:
- ${PROXY_ALIAS:-nebula}
networks:
proxy:
external: true
name: ${PROXY_NETWORK:-proxy}
+21 -9
View File
@@ -1,14 +1,20 @@
name: nebula name: nebula
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
# Zwei Betriebsarten aus einer Datei: # Diese Datei veroeffentlicht nur einen einzigen Port, und den auf dem
# Loopback: den der Anwendung. Die Datenbank bleibt im Docker-Netz.
# #
# 1) Standalone-Entwicklung – nur die Datenbank im Container: # 1) Alles im Container (Server wie Entwicklung):
# docker compose up -d db # docker compose --profile full up -d --build
#
# 2) Standalone-Entwicklung – Datenbank im Container, App auf dem Host.
# Dafuer muss Postgres auf dem Host erreichbar sein, also mit dem
# Entwicklungs-Overlay (oder kurz: npm run docker:db):
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db
# npm run dev # npm run dev
# #
# 2) Alles im Container: # 3) Server ohne jede Portfreigabe, hinter einem Reverse Proxy im
# docker compose --profile full up -d --build # gemeinsamen Docker-Netz: siehe docker-compose.server.yml
# #
# Optionales DB-Webinterface: # Optionales DB-Webinterface:
# docker compose --profile tools up -d adminer → http://localhost:8080 # docker compose --profile tools up -d adminer → http://localhost:8080
@@ -25,8 +31,10 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-nebula} POSTGRES_DB: ${POSTGRES_DB:-nebula}
# Deterministische Sortierung unabhaengig vom Host-Locale # Deterministische Sortierung unabhaengig vom Host-Locale
POSTGRES_INITDB_ARGS: "--encoding=UTF8" POSTGRES_INITDB_ARGS: "--encoding=UTF8"
ports: # Absichtlich keine Portfreigabe: die Anwendung erreicht die Datenbank im
- "${POSTGRES_PORT:-5432}:5432" # Container ueber "db:5432", und backup.sh geht ueber "docker exec". Nach
# aussen muss hier nichts. Wer die Datenbank vom Host aus braucht – fuer
# "npm run dev" oder psql – nimmt docker-compose.dev.yml dazu.
volumes: volumes:
- nebula-db-data:/var/lib/postgresql/data - nebula-db-data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -73,7 +81,11 @@ services:
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
ports: ports:
- "${APP_PORT:-3000}:3000" # Nur auf dem Loopback: nach draussen kommt man ueber den Reverse Proxy.
# Docker schreibt seine Freigaben an ufw vorbei direkt in iptables – eine
# Bindung auf 0.0.0.0 stuende also im Netz, auch bei aktiver Firewall.
# Ganz ohne Host-Port: docker-compose.server.yml.
- "${APP_PORT:-127.0.0.1:3000}:3000"
adminer: adminer:
profiles: ["tools"] profiles: ["tools"]
@@ -87,7 +99,7 @@ services:
ADMINER_DEFAULT_SERVER: db ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula ADMINER_DESIGN: dracula
ports: ports:
- "${ADMINER_PORT:-8080}:8080" - "${ADMINER_PORT:-127.0.0.1:8080}:8080"
volumes: volumes:
nebula-db-data: nebula-db-data:
Regular → Executable
View File
+603
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,600 @@ 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 }) => {
await login(request, ADMIN);
const angelegt = [];
for (const name of ["Brett A", "Brett B", "Brett C"]) {
const response = await request.post("/api/projects", { data: { name, status: "IDEA" } });
angelegt.push((await response.json()).project);
}
// Neue Projekte haengen sich hinten an ihre Spalte an – fortlaufend, nicht
// alle auf denselben Platz. Absolut zaehlen laesst sich nicht: in der
// Spalte stehen schon Projekte aus frueheren Tests.
const [a, b, c] = angelegt.map((p) => p.position);
expect([b, c]).toEqual([a + 1, a + 2]);
const ids = angelegt.map((p) => p.id);
const vorher = angelegt[0].updatedAt;
// Umdrehen und dabei eines in eine andere Spalte schieben.
const gedreht = [ids[2], ids[1]];
const reorder = await request.patch("/api/projects/reorder", {
data: { columns: { IDEA: gedreht, IN_DEVELOPMENT: [ids[0]] } },
});
expect(reorder.ok()).toBeTruthy();
const alle = (await (await request.get("/api/projects")).json()).projects;
const finde = (id: string) => alle.find((p: { id: string }) => p.id === id);
expect(finde(ids[2]).position).toBe(0);
expect(finde(ids[1]).position).toBe(1);
expect(finde(ids[0]).status).toBe("IN_DEVELOPMENT");
// Reines Umsortieren darf das Aenderungsdatum nicht anfassen – sonst
// schoebe ein Aufraeumen im Brett alles in "Zuletzt geaendert" nach oben.
expect(new Date(finde(ids[1]).updatedAt).getTime()).toBe(
new Date(angelegt[1].updatedAt).getTime(),
);
// Das verschobene dagegen hat sich wirklich geaendert.
expect(new Date(finde(ids[0]).updatedAt).getTime()).toBeGreaterThan(
new Date(vorher).getTime(),
);
// Mehrfachauswahl: Tags ergaenzen und wieder entfernen.
const ergaenzt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["Sammel", "test"], tagMode: "add" },
});
expect((await ergaenzt.json()).changed).toBe(3);
const mitTags = (await (await request.get("/api/projects")).json()).projects;
// Kleingeschrieben abgelegt, wie ueberall.
expect(mitTags.find((p: { id: string }) => p.id === ids[0]).tags).toContain("sammel");
const entfernt = await request.post("/api/projects/bulk", {
data: { ids, action: "tags", tags: ["sammel"], tagMode: "remove" },
});
expect((await entfernt.json()).changed).toBe(3);
// Status fuer mehrere: die Verschobenen landen hintereinander, nicht
// uebereinander auf derselben Position.
const verschoben = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await verschoben.json()).changed).toBe(3);
const geplant = (await (await request.get("/api/projects?status=PLANNING")).json()).projects;
const positionen = geplant.map((p: { position: number }) => p.position).sort();
expect(new Set(positionen).size).toBe(positionen.length);
// Wer schon so steht, wird nicht noch einmal angefasst.
const nochmal = await request.post("/api/projects/bulk", {
data: { ids, action: "status", status: "PLANNING" },
});
expect((await nochmal.json()).changed).toBe(0);
// Fremde IDs fallen still unter den Tisch, statt etwas anzurichten.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const untergeschoben = await fremd.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await untergeschoben.json()).changed).toBe(0);
const uebrig = (await (await request.get("/api/projects")).json()).projects;
expect(uebrig.filter((p: { id: string }) => ids.includes(p.id))).toHaveLength(3);
const fremdSortieren = await fremd.patch("/api/projects/reorder", {
data: { columns: { DONE: ids } },
});
expect((await fremdSortieren.json()).updated).toBe(0);
await fremd.dispose();
// Und zum Schluss die eigenen wirklich loeschen.
const geloescht = await request.post("/api/projects/bulk", {
data: { ids, action: "delete" },
});
expect((await geloescht.json()).changed).toBe(3);
});
test("Wiederkehrende Aufgaben legen die naechste Fassung an", async ({ request }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Wiederkehrend" } })).json()
).project;
const woechentlich = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, {
data: {
title: "Abhaengigkeiten pruefen",
dueDate: "2026-07-06T12:00:00",
recurrence: "WEEKLY",
labels: ["wartung"],
},
})
).json()
).task;
// Ein Zwischenschritt loest noch nichts aus.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { status: "DOING" } });
let alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(1);
// Erst der Uebergang nach ERLEDIGT.
const erledigt = await request.patch(`/api/tasks/${woechentlich.id}`, {
data: { status: "DONE" },
});
expect((await erledigt.json()).next).not.toBeNull();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
const folge = alle.find((task: { id: string }) => task.id !== woechentlich.id);
expect(folge.status).toBe("TODO");
expect(folge.title).toBe("Abhaengigkeiten pruefen");
expect(folge.labels).toEqual(["wartung"]);
expect(folge.recurrence).toBe("WEEKLY");
expect(folge.recurredFrom).toBe(woechentlich.id);
// Der Termin liegt in der Zukunft und auf demselben Wochentag.
expect(new Date(folge.dueDate).getTime()).toBeGreaterThan(Date.now());
expect(new Date(folge.dueDate).getUTCDay()).toBe(1);
// Erneut speichern erzeugt keine dritte.
await request.patch(`/api/tasks/${woechentlich.id}`, { data: { title: "Umbenannt" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(2);
// Der zweite Weg: durch Ziehen in die Spalte ERLEDIGT.
const gezogen = await request.patch(`/api/projects/${projekt.id}/tasks/reorder`, {
data: { columns: { DONE: [folge.id] } },
});
expect(gezogen.ok()).toBeTruthy();
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(3);
expect(alle.filter((task: { status: string }) => task.status === "TODO")).toHaveLength(1);
// Eine einmalige Aufgabe bleibt einmalig.
const einmalig = (
await (
await request.post(`/api/projects/${projekt.id}/tasks`, { data: { title: "Nur einmal" } })
).json()
).task;
await request.patch(`/api/tasks/${einmalig.id}`, { data: { status: "DONE" } });
alle = (await (await request.get(`/api/projects/${projekt.id}/tasks`)).json()).tasks;
expect(alle).toHaveLength(4);
});
test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Projekt herrichten, das als Vorlage taugt.
const quelle = (
await (
await request.post("/api/projects", {
data: { name: "Vorlagenquelle", status: "PLANNING", priority: 3, tags: ["web", "docker"] },
})
).json()
).project;
await request.post(`/api/projects/${quelle.id}/notes`, {
data: { content: "Checkliste vor dem Start." },
});
const erledigt = (
await (
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Schon fertig" } })
).json()
).task;
await request.patch(`/api/tasks/${erledigt.id}`, { data: { status: "DONE" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "Repo anlegen" } });
await request.post(`/api/projects/${quelle.id}/tasks`, { data: { title: "CI einrichten" } });
const gesichert = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App", description: "Mit Docker und CI" },
});
expect(gesichert.status()).toBe(201);
const vorlage = (await gesichert.json()).template;
expect(vorlage.tags).toEqual(["web", "docker"]);
expect(vorlage.status).toBe("PLANNING");
expect(vorlage.notes).toHaveLength(1);
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu tun ist.
expect(vorlage.tasks).toHaveLength(2);
expect(vorlage.tasks.map((task: { title: string }) => task.title)).not.toContain("Schon fertig");
// Derselbe Name ein zweites Mal wird abgewiesen.
const doppelt = await request.post("/api/templates/from-project", {
data: { projectId: quelle.id, name: "Web-App" },
});
expect(doppelt.status()).toBe(409);
// Anwenden: Notizen und Aufgaben entstehen mit dem Projekt.
const neu = (
await (
await request.post("/api/projects", {
data: { name: "Aus Vorlage", status: "IDEA", templateId: vorlage.id },
})
).json()
).project;
const geladen = (await (await request.get(`/api/projects/${neu.id}`)).json()).project;
expect(geladen.notes).toHaveLength(1);
expect(geladen.tasks).toHaveLength(2);
// Die Reihenfolge der Vorlage bleibt erhalten.
expect(geladen.tasks.map((task: { title: string }) => task.title)).toEqual([
"Repo anlegen",
"CI einrichten",
]);
// Der ausdrueckliche Status gewinnt gegen den der Vorlage.
expect(geladen.status).toBe("IDEA");
// Der Verlauf haelt die Herkunft fest.
const verlauf = (await (await request.get(`/api/projects/${neu.id}/activity`)).json()).activities;
expect(verlauf.some((entry: { summary: string }) => entry.summary.includes("Web-App"))).toBe(true);
// Eine fremde Vorlage laesst sich nicht unterschieben.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/templates")).json()).templates).toHaveLength(0);
const untergeschoben = await fremd.post("/api/projects", {
data: { name: "Klau", templateId: vorlage.id },
});
expect(untergeschoben.status()).toBe(404);
expect((await fremd.delete(`/api/templates/${vorlage.id}`)).status()).toBe(404);
await fremd.dispose();
// Und die Vorlage ueberlebt das Loeschen ihres Ursprungsprojekts.
await request.delete(`/api/projects/${quelle.id}`);
const nochDa = (await (await request.get("/api/templates")).json()).templates;
expect(nochDa).toHaveLength(1);
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 }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
maxRedirects: 0,
});
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
await anonym.dispose();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN); await login(request, ADMIN);
+262 -1
View File
@@ -72,9 +72,17 @@ test("Aufgaben anlegen und per Ziehen verschieben", async ({ page }) => {
// dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese // dnd-kit wertet die Bewegung in einem eigenen Frame aus. Ohne diese
// Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck. // Pause faende das Loslassen noch kein Ziel und die Karte spraenge zurueck.
await page.waitForTimeout(250); await page.waitForTimeout(250);
// Auf die Antwort warten, bevor neu geladen wird. Sonst bricht das Neuladen
// den noch laufenden Aufruf ab, und der Test schlaegt gelegentlich fehl,
// obwohl die Anwendung in Ordnung ist – genau so ist es passiert.
const gespeichert = page.waitForResponse(
(response) => response.url().includes("/tasks/reorder") && response.request().method() === "PATCH",
);
await page.mouse.up(); await page.mouse.up();
await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible(); await expect(inArbeit.getByText("Erste Aufgabe")).toBeVisible();
expect((await gespeichert).ok()).toBeTruthy();
// Und der Server hat es auch so gespeichert. // Und der Server hat es auch so gespeichert.
await page.reload(); await page.reload();
@@ -98,6 +106,192 @@ test("Volltextsuche findet Notizen", async ({ page }) => {
await expect(page.getByText("Treffer in Notizen")).toBeVisible(); await expect(page.getByText("Treffer in Notizen")).toBeVisible();
}); });
test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Liste" }).click();
await expect(page.getByRole("link", { name: /Projekt Oberflaechentest/ })).toBeVisible();
// Der eigentliche Punkt der Ansicht: sie passt in die Breite. Frueher schob
// die Steuerleiste den Knopf "Neues Projekt" aus dem Bild und die ganze
// Seite liess sich seitwaerts schieben.
const masse = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(masse.scrollWidth).toBeLessThanOrEqual(masse.clientWidth + 1);
// Auch der Knopf muss erreichbar bleiben – auf dieser Breite traegt er nur
// noch das Pluszeichen und lebt von seinem aria-label. Geprueft wird die
// rechte Kante: genau die lag vorher ausserhalb des Bildschirms.
const knopf = page.getByRole("button", { name: "Neues Projekt" });
await expect(knopf).toBeVisible();
const kasten = await knopf.boundingBox();
expect(kasten).not.toBeNull();
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
});
test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.getByRole("button", { name: "Schnell erfassen" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas.
await page.fill("#quick-title", "Aus der Bahn notiert");
await dialog.getByRole("button", { name: "Anlegen" }).click();
// Der Dialog bleibt offen und bestaetigt – das ist der Punkt der
// Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen.
await expect(dialog.getByText("Angelegt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible();
await expect(page.locator("#quick-title")).toHaveValue("");
// Aufgabe: derselbe Dialog, ein Feld mehr.
await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click();
await page.fill("#quick-title", "Unterwegs eingefallen");
await expect(page.locator("#quick-project")).toBeVisible();
await dialog.getByRole("button", { name: "Anlegen" }).click();
await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
// Die Idee steht als Projekt im Dashboard.
await page.goto("/");
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 }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
// Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf,
// obwohl sie in einem anderen Projekt haengt.
const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" });
await expect(zeile).toBeVisible();
// Abhaken aus der Uebersicht heraus.
await zeile.getByRole("button", { name: /abhaken/ }).click();
// Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste.
await expect(zeile).toBeHidden();
await page.getByLabel("Erledigte einblenden").check();
await expect(
page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }),
).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();
@@ -105,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.
@@ -119,6 +313,73 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeHidden();
}); });
test("Mehrfachauswahl aendert mehrere Projekte auf einmal", async ({ page }) => {
const kaestchen = page.locator('input[aria-label$="auswaehlen"]');
const anzahl = await kaestchen.count();
expect(anzahl).toBeGreaterThan(0);
// Ohne Auswahl gibt es keine Leiste – sie soll nicht dauerhaft im Weg sein.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
await kaestchen.first().check();
await expect(page.getByText("1 ausgewählt")).toBeVisible();
await page.getByRole("button", { name: "Tags", exact: true }).click();
await page.fill("#bulk-tags", "sammeltest");
await page.getByRole("button", { name: "Hinzufügen" }).click();
await expect(page.getByText("#sammeltest").first()).toBeVisible();
// Nach getaner Arbeit ist die Auswahl aufgehoben.
await expect(page.getByText(/ausgewählt/)).toBeHidden();
});
test("Die Versionsnummer oeffnet den Aenderungsverlauf", async ({ page }) => {
await page.getByRole("button", { name: /^v\d+\.\d+\.\d+$/ }).click();
// Ueber die Rolle, nicht ueber den Text: das Wort steht auch in einem der
// Eintraege, und ein blosser Textvergleich waere mehrdeutig.
const dialog = page.getByRole("dialog", { name: "Änderungsverlauf" });
await expect(dialog).toBeVisible();
// Die laufende Fassung ist als solche gekennzeichnet.
await expect(dialog.getByText("installiert")).toBeVisible();
// Und es steht wirklich etwas drin.
await expect(dialog.getByRole("heading", { level: 3 }).first()).toBeVisible();
await expect(dialog.getByText("Neu").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
});
test("Das Manifest liegt bereit, auch ohne Anmeldung", async ({ page, playwright }) => {
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Ohne diesen Weg an der Zugangsschranke vorbei liesse sich die Anwendung
// nicht installieren: der Browser holt das Manifest, bevor jemand angemeldet ist.
const antwort = await anonym.get("/manifest.webmanifest");
expect(antwort.ok()).toBeTruthy();
const manifest = await antwort.json();
expect(manifest.name).toContain("Nebula");
expect(manifest.display).toBe("standalone");
expect(manifest.icons.some((icon: { purpose: string }) => icon.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) {
const bild = await anonym.get(icon.src);
expect(bild.ok(), icon.src).toBeTruthy();
expect(bild.headers()["content-type"]).toContain("image/png");
}
await anonym.dispose();
// Die Seite muss ein Symbol verweisen. Fehlt es, sucht der Browser
// /favicon.ico – das es hier nicht gibt, und die Konsole fuellt sich mit
// 404ern. Genau das ist beim Ergaenzen des Apple-Symbols passiert.
await page.goto("/");
await expect(page.locator('link[rel="icon"]').first()).toHaveAttribute("href", /icon/);
});
test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => { test("Abmelden fuehrt zurueck zur Anmeldung", async ({ page }) => {
await page.getByRole("button", { name: /Benutzermenue/ }).click(); await page.getByRole("button", { name: /Benutzermenue/ }).click();
await page.getByRole("menuitem", { name: "Abmelden" }).click(); await page.getByRole("menuitem", { name: "Abmelden" }).click();
+15
View File
@@ -9,6 +9,21 @@ const nextConfig: NextConfig = {
poweredByHeader: false, poweredByHeader: false,
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht. // Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
serverExternalPackages: ["@prisma/client", "prisma"], serverExternalPackages: ["@prisma/client", "prisma"],
images: {
/**
* Der Bildoptimierer ist abgeschaltet.
*
* Nebula benutzt next/image nirgends – die paar Symbole sind SVG und
* PNG in fester Groesse. Der Endpunkt /_next/image waere aber trotzdem
* da, und zwar ohne Anmeldung: die Middleware nimmt ihn aus, weil dort
* sonst nur Rechenzeit verbraucht wuerde.
*
* Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
* regelmaessig Schwachstellen gibt (aktuell CVE-2026-33327 und drei
* weitere). Eine Angriffsflaeche ohne Gegenwert – also weg damit.
*/
unoptimized: true,
},
}; };
export default nextConfig; export default nextConfig;
+31 -6
View File
@@ -1,12 +1,12 @@
{ {
"name": "nebula", "name": "nebula",
"version": "0.9.0", "version": "1.10.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nebula", "name": "nebula",
"version": "0.9.0", "version": "1.10.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -34,6 +34,8 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
@@ -2535,6 +2537,16 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/axe-core": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz",
"integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==",
"dev": true,
"license": "MPL-2.0",
"engines": {
"node": ">=4"
}
},
"node_modules/bail": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -2574,6 +2586,19 @@
} }
} }
}, },
"node_modules/c12/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/camelcase": { "node_modules/camelcase": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -2866,10 +2891,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
"devOptional": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
+4 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.0.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": {
@@ -16,7 +16,7 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:reset": "prisma migrate reset", "db:reset": "prisma migrate reset",
"docker:db": "docker compose up -d db", "docker:db": "docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d db",
"docker:db:down": "docker compose stop db", "docker:db:down": "docker compose stop db",
"docker:up": "docker compose --profile full up -d --build", "docker:up": "docker compose --profile full up -d --build",
"docker:down": "docker compose --profile full down", "docker:down": "docker compose --profile full down",
@@ -30,9 +30,6 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:all": "npm test && npm run test:e2e" "test:all": "npm test && npm run test:e2e"
}, },
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
@@ -59,6 +56,8 @@
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.5", "@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"axe-core": "^4.12.1",
"dotenv": "^17.4.2",
"prisma": "^6.8.2", "prisma": "^6.8.2",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"tsx": "^4.19.4", "tsx": "^4.19.4",
+24
View File
@@ -0,0 +1,24 @@
import { config as loadEnv } from "dotenv";
import { defineConfig } from "prisma/config";
/**
* Konfiguration der Prisma-CLI.
*
* Loest den Abschnitt `prisma` in der package.json ab, der mit Prisma 7
* wegfaellt.
*
* **Der Aufruf von dotenv ist nicht optional.** Sobald diese Datei
* existiert, liest Prisma die .env nicht mehr von selbst ein – ohne die
* folgenden Zeilen stuende `migrate deploy` plotzlich ohne DATABASE_URL da,
* und zwar mit einer Fehlermeldung, die nicht darauf hindeutet. Erst .env,
* dann .env.local: eine oertliche Abweichung soll gewinnen.
*/
loadEnv({ path: ".env", quiet: true });
loadEnv({ path: ".env.local", override: true, quiet: true });
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
seed: "tsx prisma/seed.ts",
},
});
@@ -0,0 +1,20 @@
-- Freie Reihenfolge im Projektbrett.
--
-- Der Bestand bekommt seine Position aus der bisherigen Anzeige: je Konto und
-- Spalte nach Aenderungsdatum absteigend. Ohne diesen Schritt staenden alle
-- auf 0 und das Brett zeigte eine willkuerliche Reihenfolge, die sich bei
-- jedem Laden anders ergeben koennte.
ALTER TABLE "Project" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
UPDATE "Project" AS p
SET "position" = nummeriert.rang - 1
FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "ownerId", "status" ORDER BY "updatedAt" DESC, "id"
) AS rang
FROM "Project"
) AS nummeriert
WHERE nummeriert."id" = p."id";
CREATE INDEX "Project_ownerId_status_position_idx" ON "Project"("ownerId", "status", "position");
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "commitCalendar" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "commitCalendarAt" TIMESTAMP(3);
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "ProjectTemplate" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"summary" TEXT,
"projectDescription" TEXT,
"status" "ProjectStatus" NOT NULL DEFAULT 'IDEA',
"priority" INTEGER NOT NULL DEFAULT 2,
"accent" TEXT NOT NULL DEFAULT 'violet',
"tags" TEXT[] DEFAULT ARRAY[]::TEXT[],
"progressFromTasks" BOOLEAN NOT NULL DEFAULT false,
"notes" JSONB NOT NULL DEFAULT '[]',
"tasks" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name");
-- AddForeignKey
ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "Recurrence" AS ENUM ('DAILY', 'WEEKLY', 'BIWEEKLY', 'MONTHLY');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "recurredFrom" TEXT,
ADD COLUMN "recurrence" "Recurrence";
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
ADD COLUMN "readmeAt" TIMESTAMP(3),
ADD COLUMN "readmeBaseUrl" TEXT,
ADD COLUMN "readmePath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
@@ -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", '')));
+90
View File
@@ -71,6 +71,16 @@ enum ActivityKind {
SYNC_FAILED SYNC_FAILED
} }
/// Wie oft sich eine Aufgabe wiederholt. Bewusst grob: feinere Regeln
/// (jeden zweiten Dienstag im Monat) braucht ein Projektdashboard nicht,
/// und sie waeren ohne Kalenderansicht kaum nachvollziehbar.
enum Recurrence {
DAILY
WEEKLY
BIWEEKLY
MONTHLY
}
/// Spalten des Aufgabenbretts innerhalb eines Projekts. /// Spalten des Aufgabenbretts innerhalb eines Projekts.
enum TaskStatus { enum TaskStatus {
TODO TODO
@@ -118,6 +128,10 @@ model User {
/// sperrt sich aus, wer die Einrichtung abbricht. /// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String? totpSecretCipher String?
totpEnabledAt DateTime? totpEnabledAt DateTime?
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
totpLastCounter Int?
sessions Session[] sessions Session[]
credentials Credential[] credentials Credential[]
@@ -127,6 +141,7 @@ model User {
activities Activity[] activities Activity[]
recoveryCodes RecoveryCode[] recoveryCodes RecoveryCode[]
pendingLogins PendingLogin[] pendingLogins PendingLogin[]
templates ProjectTemplate[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -315,6 +330,9 @@ model Project {
accent String @default("violet") accent String @default("violet")
tags String[] @default([]) tags String[] @default([])
favorite Boolean @default(false) favorite Boolean @default(false)
/// Rang innerhalb der Statusspalte im Brett. Nur dort wirksam – die
/// uebrigen Ansichten folgen der gewaehlten Sortierung.
position Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen. /// Einrichten des ersten Kontos uebernommen werden koennen.
@@ -343,6 +361,7 @@ model Project {
@@index([status]) @@index([status])
@@index([updatedAt]) @@index([updatedAt])
@@index([ownerId]) @@index([ownerId])
@@index([ownerId, status, position])
} }
/// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren – /// Ein Repository eines Projekts. Ein Vorhaben besteht oft aus mehreren –
@@ -388,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())
@@ -445,6 +474,15 @@ model Task {
/// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist.
doneAt DateTime? doneAt DateTime?
/// Wiederholt sich die Aufgabe? Wandert sie nach ERLEDIGT, entsteht die
/// naechste Fassung mit fortgeschriebenem Faelligkeitsdatum. Die erledigte
/// bleibt stehen – sonst ginge die Historie verloren.
recurrence Recurrence?
/// Aus welcher Aufgabe diese hervorgegangen ist. Nur zur Nachvollziehbarkeit;
/// bewusst ohne Fremdschluessel, damit das Loeschen der Vorgaengerin die
/// Nachfolgerin nicht mitnimmt.
recurredFrom String?
/// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link /// Stammt die Aufgabe aus einem Git-Issue? Nummer, Repository und Link
/// merken, damit dasselbe Issue nicht zweimal uebernommen wird. /// merken, damit dasselbe Issue nicht zweimal uebernommen wird.
sourceIssue Int? sourceIssue Int?
@@ -462,6 +500,41 @@ model Task {
@@index([projectId, status, position]) @@index([projectId, status, position])
} }
/// Eine Vorlage fuer neue Projekte: Stammdaten, Tags, Notizen und Aufgaben.
///
/// Bewusst benutzereigen und nicht fest eingebaut. Welche Tags und welche
/// Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet – eine
/// mitgelieferte Liste waere geraten.
model ProjectTemplate {
id String @id @default(cuid())
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
name String
/// Wozu die Vorlage gut ist – erscheint in der Auswahl.
description String?
// ── Was die Vorlage in das neue Projekt schreibt ───────────
summary String?
projectDescription String?
status ProjectStatus @default(IDEA)
priority Int @default(2)
accent String @default("violet")
tags String[] @default([])
progressFromTasks Boolean @default(false)
/// [{ title, content, pinned }]
notes Json @default("[]")
/// [{ title, description, status, labels }]
tasks Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([ownerId, name])
@@index([ownerId])
}
/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert. /// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert.
model Activity { model Activity {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -546,6 +619,23 @@ model RepoSnapshot {
/// Commits je Tag der letzten 30 Tage, aeltester zuerst. /// Commits je Tag der letzten 30 Tage, aeltester zuerst.
commitActivity Json @default("[]") commitActivity Json @default("[]")
/// Commits je Tag ueber bis zu ein Jahr – die Heatmap.
/// { from, to, days: { "2026-07-26": 3 }, source, truncated }
/// Getrennt vom uebrigen Stand, weil das Holen zusaetzliche Aufrufe kostet
/// und deshalb hoechstens einmal am Tag geschieht.
commitCalendar Json @default("{}")
commitCalendarAt DateTime?
/// Das README des Repositories, roh als Markdown. Wie der Kalender
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
/// Abgleich laeuft im Zweifel stuendlich.
readme String?
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
readmePath String?
/// Wogegen relative Links im README aufzuloesen sind.
readmeBaseUrl String?
readmeAt DateTime?
// ── Letzter CI-Lauf auf dem Default-Branch ───────────────── // ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled /// success | failure | running | pending | cancelled
ciState String? ciState String?
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Regular → Executable
View File
Regular → Executable
View File
+79
View File
@@ -0,0 +1,79 @@
#!/bin/sh
# ─────────────────────────────────────────────────────────────
# Legt die .env an und wuerfelt ein APP_SECRET.
#
# Dasselbe wie scripts/setup.mjs, aber ohne Node: auf einem reinen
# Docker-Host ist keins installiert, und nur zum Erzeugen einer Textdatei
# eines zu verlangen waere albern. Braucht nichts ausser einer POSIX-Shell.
#
# Aufruf: ./scripts/setup.sh
# ./scripts/setup.sh --force (vorhandene .env ueberschreiben)
# ─────────────────────────────────────────────────────────────
set -eu
root="$(cd "$(dirname "$0")/.." && pwd)"
env_datei="$root/.env"
vorlage="$root/.env.example"
force=0
[ "${1:-}" = "--force" ] && force=1
if [ -t 1 ]; then
gruen=$'\033[32m'; gelb=$'\033[33m'; cyan=$'\033[36m'; dim=$'\033[2m'; aus=$'\033[0m'
else
gruen=""; gelb=""; cyan=""; dim=""; aus=""
fi
[ -f "$vorlage" ] || { echo "${gelb}.env.example nicht gefunden – abgebrochen.${aus}" >&2; exit 1; }
# ── Zufall ───────────────────────────────────────────────────
# openssl ist auf den meisten Systemen da, aber nicht auf allen. /dev/urandom
# gibt es ueberall, wo diese Shell laeuft – und base64 ebenso.
zufall() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 32
else
head -c 32 /dev/urandom | base64
fi | tr -d '\n'
}
if [ -f "$env_datei" ] && [ "$force" -eq 0 ]; then
echo "${gelb}·${aus} .env existiert bereits – sie wird nicht ueberschrieben."
echo " ${dim}Mit --force neu anlegen (die alte wird gesichert).${aus}"
else
if [ -f "$env_datei" ]; then
sicherung="$env_datei.$(date +%Y%m%d%H%M%S).bak"
cp "$env_datei" "$sicherung"
echo "${gelb}·${aus} Alte .env gesichert: $(basename "$sicherung")"
fi
secret="$(zufall)"
# Das Geheimnis kann / und + enthalten; als sed-Ersetzung waeren das
# Sonderzeichen. Deshalb zeilenweise ersetzen statt mit einem Ausdruck.
: > "$env_datei"
while IFS= read -r zeile || [ -n "$zeile" ]; do
case "$zeile" in
APP_SECRET=*) printf 'APP_SECRET="%s"\n' "$secret" >> "$env_datei" ;;
*) printf '%s\n' "$zeile" >> "$env_datei" ;;
esac
done < "$vorlage"
chmod 600 "$env_datei"
echo "${gruen}✓${aus} .env angelegt, APP_SECRET zufaellig erzeugt, Rechte auf 600."
fi
echo
echo "${cyan}Noch anzupassen${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " APP_URL die Adresse, unter der du Nebula aufrufst"
echo " ${dim}bestimmt die WebAuthn-rpID – passt sie nicht, scheitern Passkeys${aus}"
echo " POSTGRES_PASSWORD die Voreinstellung ist fuer Entwicklung gedacht"
echo " APP_PORT auf einem Server: 127.0.0.1:3000 hinter den Reverse Proxy"
echo
echo "${cyan}Danach${aus}"
echo "${dim}─────────────────────────────────────────────${aus}"
echo " docker compose --profile full up -d --build"
echo " ${dim}Der Container spielt die Migrationen beim Start selbst ein.${aus}"
echo
echo " Ausfuehrlich: DEPLOYMENT.md"
echo
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# Nebula auf einem Server aktualisieren.
#
# Holt den neuen Stand, sichert die Datenbank, baut das Abbild, spielt die
# Migrationen ein und startet neu. Bricht ab, sobald ein Schritt scheitert –
# und sagt dann, wie zurueckzukommen ist.
#
# Aufruf: ./scripts/update.sh
# ./scripts/update.sh --no-pull (nur neu bauen und starten)
# ./scripts/update.sh --ref v1.4.0 (auf einen Stand festlegen)
# ./scripts/update.sh --yes (ohne Rueckfrage)
#
# Rueckgabe 0 heisst: die neue Fassung laeuft und antwortet gesund.
# ─────────────────────────────────────────────────────────────
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
pull=1
ref=""
zusage=0
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) pull=0 ;;
--ref) ref="${2:-}"; shift ;;
--yes|-y) zusage=1 ;;
-h|--help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
# ── Farben, aber nur wenn ein Terminal zusieht ───────────────
if [ -t 1 ]; then
rot=$'\033[31m'; gruen=$'\033[32m'; gelb=$'\033[33m'; fett=$'\033[1m'; aus=$'\033[0m'
else
rot=""; gruen=""; gelb=""; fett=""; aus=""
fi
# Die Version aus der package.json lesen – ohne Node. Auf einem reinen
# Docker-Host ist keins installiert, und dieses Skript soll dort laufen.
paket_version() {
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$root/package.json" \
| head -n 1
}
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
abbruch() { printf '\n%s✗ %s%s\n' "$rot" "$1" "$aus" >&2; exit 1; }
# ── Voraussetzungen ──────────────────────────────────────────
schritt "Voraussetzungen"
command -v docker >/dev/null || abbruch "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst './scripts/setup.sh' ausfuehren."
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
ok "docker, compose und .env vorhanden"
# APP_SECRET muss gesetzt sein – sonst liegen die Git-Tokens faktisch offen.
if grep -qE '^\s*APP_SECRET\s*=\s*("?)(nebula-dev-secret[^"]*|)\1\s*$' "$root/.env"; then
abbruch "APP_SECRET ist leer oder noch der Entwicklungswert. Bitte in der .env setzen."
fi
ok "APP_SECRET gesetzt"
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
version_vorher="$(paket_version)"
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
# ── Neuen Stand holen ────────────────────────────────────────
if [ "$pull" -eq 1 ]; then
schritt "Neuen Stand holen"
schmutz="$(git status --porcelain)"
if [ -n "$schmutz" ]; then
# Nicht nur meckern, sondern zeigen, worum es geht – sonst muss man
# raten, was man da eigentlich verwerfen soll.
printf '\n%sGeaendert gegenueber dem Stand im Git:%s\n' "$fett" "$aus"
printf '%s\n' "$schmutz" | sed 's/^/ /'
printf '\n Verwerfen: git checkout -- .\n'
printf ' Behalten: git stash\n'
abbruch "Das Arbeitsverzeichnis ist nicht sauber."
fi
git fetch --all --tags --quiet
if [ -n "$ref" ]; then
git checkout --quiet "$ref"
ok "Auf $ref gewechselt"
else
zweig="$(git rev-parse --abbrev-ref HEAD)"
git pull --ff-only --quiet origin "$zweig"
ok "$zweig aktualisiert"
fi
nachher="$(git rev-parse --short HEAD)"
version_nachher="$(paket_version)"
if [ "$vorher" = "$nachher" ]; then
warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
else
printf ' Neu: v%s (%s)\n' "$version_nachher" "$nachher"
printf '\n Was dazukam:\n'
git --no-pager log --oneline --no-decorate "$vorher..$nachher" 2>/dev/null | sed 's/^/ /' || true
fi
else
warnung "Ohne --pull: der vorhandene Stand wird neu gebaut."
fi
# ── Rueckfrage ───────────────────────────────────────────────
if [ "$zusage" -eq 0 ] && [ -t 0 ]; then
printf '\n%sWeiter? Die Datenbank wird vorher gesichert. [j/N] %s' "$fett" "$aus"
read -r antwort
case "$antwort" in [jJyY]*) ;; *) abbruch "Abgebrochen." ;; esac
fi
# ── Datenbank sichern ────────────────────────────────────────
schritt "Datenbank sichern"
# Die Datenbank muss laufen, sonst gibt es nichts zu sichern.
docker compose up -d db >/dev/null
sicherung=""
if [ -x "$root/scripts/backup.sh" ]; then
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
# backup.sh setzt den Pfad in Anfuehrungszeichen – die gehoeren nicht in
# den Namen, sonst laesst sich der Vorschlag am Ende nicht kopieren.
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ "]*backups/[^ "]*\.sql[^ "]*' | tail -n 1 || true)"
ok "Gesichert${sicherung:+: $sicherung}"
else
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
fi
# ── Bauen ────────────────────────────────────────────────────
schritt "Abbild bauen"
# Erst bauen, dann umschalten: ein Fehlschlag laesst die alte Fassung laufen.
docker compose --profile full build app || abbruch "Der Bau ist gescheitert. Die laufende Fassung ist unveraendert."
ok "Abbild gebaut"
# ── Starten ──────────────────────────────────────────────────
schritt "Starten"
# Die Migrationen laufen im entrypoint.sh, bevor der Server hochkommt.
docker compose --profile full up -d || abbruch "Der Start ist gescheitert."
ok "Container gestartet"
# ── Gesundheitspruefung ──────────────────────────────────────
schritt "Gesundheitspruefung"
# Gefragt wird der Container selbst, nicht der Host. Das Abbild bringt einen
# HEALTHCHECK mit, und so ist es einerlei, ob APP_PORT nur eine Nummer ist
# oder wie auf einem Server "127.0.0.1:3000" – und curl braucht es auch nicht.
gesund=0
for _ in $(seq 1 40); do
zustand="$(docker inspect --format '{{.State.Health.Status}}' nebula-app 2>/dev/null || echo "weg")"
case "$zustand" in
healthy) gesund=1; break ;;
unhealthy) break ;;
esac
sleep 3
done
if [ "$gesund" -ne 1 ]; then
printf '\n%sLetzte Zeilen aus dem Log:%s\n' "$fett" "$aus"
docker compose --profile full logs --tail 40 app || true
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
abbruch "Der Container meldet sich nach zwei Minuten nicht gesund (Zustand: ${zustand})."
fi
ok "Container meldet sich gesund"
# Die Anzeige der Adresse. Nicht die .env raten, sondern Docker fragen: mit
# docker-compose.server.yml veroeffentlicht der Container gar keinen Port und
# haengt nur im Netz des Reverse Proxy. Dann gibt "port" nichts zurueck.
adresse="$(docker compose --profile full port app 3000 2>/dev/null | tail -n 1 || true)"
if [ -n "$adresse" ]; then
port="${adresse##*:}"
if command -v curl >/dev/null 2>&1; then
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health" 2>/dev/null || true)"
[ -n "$antwort_json" ] && ok "Antwort: $antwort_json"
fi
wo="$adresse"
else
wo="im Docker-Netz (kein Host-Port veroeffentlicht)"
fi
# ── Fertig ───────────────────────────────────────────────────
version_jetzt="$(paket_version)"
printf '\n%s%s✓ Nebula v%s laeuft auf %s%s\n' "$gruen" "$fett" "$version_jetzt" "$wo" "$aus"
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
+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 });
} }
+7 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password"; import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery"; import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp"; import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config"; import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto"; import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -109,7 +109,8 @@ export async function PUT(request: Request) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409); return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
} }
if (!verifyTotp(secret, parsed.data.code)) { const fenster = matchTotp(secret, parsed.data.code);
if (fenster === null) {
return jsonError( return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?", "Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401, 401,
@@ -118,7 +119,9 @@ export async function PUT(request: Request) {
await prisma.user.update({ await prisma.user.update({
where: { id: auth.user.id }, where: { id: auth.user.id },
data: { totpEnabledAt: new Date() }, // Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
}); });
return NextResponse.json({ return NextResponse.json({
@@ -157,7 +160,7 @@ export async function DELETE(request: Request) {
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: auth.user.id }, where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null }, data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
}), }),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }), prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }), prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
+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
+23 -4
View File
@@ -8,8 +8,9 @@ import {
} from "@/lib/auth/mfa"; } from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery"; import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session"; import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp"; import { matchTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users"; import { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -63,9 +64,19 @@ export async function POST(request: Request) {
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die // Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht. // Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe); const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe) // Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
: await redeemRecoveryCode(pending.user.id, eingabe); // das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
const fenster =
istZiffern && pending.user.totpSecret
? matchTotp(pending.user.totpSecret, eingabe)
: null;
const frisch =
fenster !== null &&
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) { if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id); const weiterMoeglich = await noteMfaFailure(pending.id);
@@ -83,6 +94,14 @@ export async function POST(request: Request) {
await discardPendingLogin(pending.id); await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id); await noteSuccessfulLogin(pending.user.id);
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
if (fenster !== null) {
await prisma.user.update({
where: { id: pending.user.id },
data: { totpLastCounter: fenster },
});
}
const session = await createSession(pending.user.id, { const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"), userAgent: request.headers.get("user-agent"),
ip, ip,
+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 } },
+13 -1
View File
@@ -3,7 +3,12 @@ import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity"; import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects"; import {
nextProjectPosition,
projectDetailInclude,
projectListInclude,
uniqueSlug,
} from "@/lib/projects";
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories"; import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { refreshDerivedProgress } from "@/lib/tasks"; import { refreshDerivedProgress } from "@/lib/tasks";
@@ -82,6 +87,13 @@ export async function PATCH(request: Request, { params }: Params) {
if (data[key] !== undefined) update[key] = data[key]; if (data[key] !== undefined) update[key] = data[key];
} }
// Beim Statuswechsel ans Ende der neuen Spalte – sonst behielte das Projekt
// seinen alten Rang und saesse mitten in einer Spalte, in der es eben erst
// angekommen ist.
if (data.status !== undefined && data.status !== existing.status) {
update.position = await nextProjectPosition(auth.user.id, data.status);
}
await prisma.project.update({ where: { id }, data: update }); await prisma.project.update({ where: { id }, data: update });
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin // Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
@@ -3,7 +3,7 @@ import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks"; import { ownedProject, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation"; import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -52,11 +52,14 @@ export async function PATCH(request: Request, { params }: Params) {
const now = new Date(); const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = []; const updates: Prisma.PrismaPromise<unknown>[] = [];
/** Wer eben erst nach ERLEDIGT gewandert ist – nicht, wer dort schon lag. */
const frischErledigt: string[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) { for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => { ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return; if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId); const before = previousStatus.get(taskId);
if (status === "DONE" && before !== "DONE") frischErledigt.push(taskId);
updates.push( updates.push(
prisma.task.update({ prisma.task.update({
where: { id: taskId }, where: { id: taskId },
@@ -77,6 +80,14 @@ export async function PATCH(request: Request, { params }: Params) {
if (updates.length > 0) { if (updates.length > 0) {
await prisma.$transaction(updates); await prisma.$transaction(updates);
// Wiederkehrende Aufgaben, die eben nach ERLEDIGT gezogen wurden,
// bekommen ihre naechste Fassung. Erst nach der Transaktion: vorher
// stuende doneAt noch nicht, aus dem der Folgetermin gerechnet wird.
for (const taskId of frischErledigt) {
await spawnRecurrence(taskId);
}
await refreshDerivedProgress(id); await refreshDerivedProgress(id);
} }
+2 -1
View File
@@ -46,7 +46,7 @@ export async function POST(request: Request, { params }: Params) {
); );
} }
const { title, description, status, dueDate, labels } = parsed.data; const { title, description, status, dueDate, labels, recurrence } = parsed.data;
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
@@ -58,6 +58,7 @@ export async function POST(request: Request, { params }: Params) {
position: await nextPosition(id, status), position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null, dueDate: dueDate ? new Date(dueDate) : null,
labels, labels,
recurrence: recurrence ?? null,
doneAt: status === "DONE" ? new Date() : null, doneAt: status === "DONE" ? new Date() : null,
}, },
}); });
+150
View File
@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectBulkSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Dieselbe Aenderung fuer mehrere Projekte auf einmal.
*
* Der Weg ueber einen eigenen Endpunkt statt vieler einzelner PATCHes hat
* zwei Gruende: es ist eine Transaktion statt zwanzig Rundgaengen, und der
* Verlauf bekommt je Projekt genau einen Eintrag statt eines Schwalls.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectBulkSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { ids, action } = parsed.data;
// Die Besitzerbedingung steht zuerst: fremde IDs werden hier gar nicht
// erst eingesammelt und fallen damit still unter den Tisch.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: ids } },
select: { id: true, status: true, tags: true, favorite: true, updatedAt: true },
});
if (owned.length === 0) {
return NextResponse.json({ changed: 0, ignored: ids.length });
}
const eigene = owned.map((project) => project.id);
const entries: Parameters<typeof recordActivities>[0] = [];
if (action === "delete") {
const result = await prisma.project.deleteMany({
where: { ownerId: auth.user.id, id: { in: eigene } },
});
return NextResponse.json({ changed: result.count, ignored: ids.length - result.count });
}
const updates: Prisma.PrismaPromise<unknown>[] = [];
// Einmal vorab bestimmen und hochzaehlen. Je Projekt neu abzufragen ergaebe
// immer dieselbe Zahl – die Transaktion ist ja noch nicht geschrieben, und
// alle Verschobenen landeten uebereinander auf derselben Position.
let position =
action === "status" ? await nextPosition(auth.user.id, parsed.data.status!) : 0;
for (const project of owned) {
if (action === "status") {
const ziel = parsed.data.status!;
if (project.status === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ans Ende der Zielspalte, wie beim einzelnen Verschieben auch.
data: { status: ziel, position: position++ },
}),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(project.status, ziel),
meta: { from: project.status, to: ziel, bulk: true },
});
continue;
}
if (action === "favorite") {
const ziel = parsed.data.favorite!;
if (project.favorite === ziel) continue;
updates.push(
prisma.project.update({
where: { id: project.id },
// Ein Stern ist keine inhaltliche Aenderung.
data: { favorite: ziel, updatedAt: project.updatedAt },
}),
);
continue;
}
// action === "tags"
const gewuenscht = parsed.data.tags!.map((tag) => tag.toLowerCase());
const naechste =
parsed.data.tagMode === "add"
? Array.from(new Set([...project.tags, ...gewuenscht])).slice(0, 12)
: project.tags.filter((tag) => !gewuenscht.includes(tag));
if (naechste.length === project.tags.length &&
naechste.every((tag, index) => tag === project.tags[index])) {
continue;
}
updates.push(
prisma.project.update({ where: { id: project.id }, data: { tags: naechste } }),
);
entries.push({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_UPDATED",
summary: "Tags geändert",
meta: { tags: naechste, bulk: true },
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
changed: updates.length,
// Nicht geaendert, weil schon so, oder weil die ID nicht dem Konto gehoert.
ignored: ids.length - updates.length,
});
}
/** Naechste freie Position am Ende einer Statusspalte. */
async function nextPosition(ownerId: string, status: ProjectStatus) {
const last = await prisma.project.findFirst({
where: { ownerId, status },
orderBy: { position: "desc" },
select: { position: true },
});
return (last?.position ?? -1) + 1;
}
+3 -1
View File
@@ -8,7 +8,7 @@ import {
repositoriesOf, repositoriesOf,
type ExportedRepository, type ExportedRepository,
} from "@/lib/export/format"; } from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects"; import { nextProjectPosition, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { import {
IMPORT_MAX_BODY_BYTES, IMPORT_MAX_BODY_BYTES,
@@ -164,6 +164,8 @@ export async function POST(request: Request) {
} }
const data = { const data = {
// Hinten an die Statusspalte des Bretts, in der Reihenfolge der Datei.
position: await nextProjectPosition(auth.user.id, incoming.status),
name: incoming.name, name: incoming.name,
summary: incoming.summary ?? null, summary: incoming.summary ?? null,
description: incoming.description ?? null, description: incoming.description ?? null,
+102
View File
@@ -0,0 +1,102 @@
import { NextResponse } from "next/server";
import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Neue Reihenfolge im Brett nach dem Ziehen.
*
* Wie beim Aufgabenbrett wird je betroffener Spalte die vollstaendige Liste
* uebergeben. Status und Position ergeben sich daraus eindeutig; ein
* verlorener Aufruf laesst sich einfach erneut senden, ohne Schaden
* anzurichten.
*/
export async function PATCH(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = projectReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<ProjectStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur eigene Projekte anfassen – sonst liessen sich fremde ueber
// untergeschobene IDs verschieben.
const owned = await prisma.project.findMany({
where: { ownerId: auth.user.id, id: { in: requestedIds } },
select: { id: true, status: true, updatedAt: true },
});
const ownedIds = new Set(owned.map((project) => project.id));
const vorher = new Map(owned.map((project) => [project.id, project]));
const updates: Prisma.PrismaPromise<unknown>[] = [];
const entries: Parameters<typeof recordActivities>[0] = [];
for (const [status, ids] of Object.entries(columns) as Array<[ProjectStatus, string[]]>) {
ids.forEach((projectId, index) => {
const before = vorher.get(projectId);
if (!ownedIds.has(projectId) || !before) return;
const gewandert = before.status !== status;
updates.push(
prisma.project.update({
where: { id: projectId },
data: {
status,
position: index,
// Beim reinen Umsortieren das Aenderungsdatum festhalten. Sonst
// schoebe ein Aufraeumen im Brett saemtliche Projekte in der
// Sortierung "Zuletzt geaendert" nach oben – das Brett wuerde die
// Liste umsortieren, ohne dass sich inhaltlich etwas geaendert hat.
...(gewandert ? {} : { updatedAt: before.updatedAt }),
},
}),
);
// Ein Statuswechsel gehoert in den Verlauf, ein blosses Umsortieren
// nicht – sonst besteht er bald nur noch aus Rangaenderungen.
if (gewandert) {
entries.push({
projectId,
userId: auth.user.id,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, status),
meta: { from: before.status, to: status },
});
}
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await recordActivities(entries);
}
return NextResponse.json({
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+67 -4
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity"; import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { projectListInclude, uniqueSlug } from "@/lib/projects"; import { nextProjectPosition, projectListInclude, uniqueSlug } from "@/lib/projects";
import { applyLeadingRepo } from "@/lib/repositories"; import { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -74,6 +74,20 @@ export async function POST(request: Request) {
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
} }
// Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die
// Oberflaeche schon in die Felder uebernommen, damit man sie vor dem
// Speichern noch sieht und aendern kann.
const vorlage = data.templateId
? await prisma.projectTemplate.findFirst({
where: { id: data.templateId, ownerId: auth.user.id },
select: { name: true, notes: true, tasks: true },
})
: null;
if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404);
const notes = templateNotes(vorlage?.notes);
const tasks = templateTasks(vorlage?.tasks);
const angelegt = await prisma.project.create({ const angelegt = await prisma.project.create({
data: { data: {
ownerId: auth.user.id, ownerId: auth.user.id,
@@ -87,6 +101,11 @@ export async function POST(request: Request) {
accent: data.accent, accent: data.accent,
tags: data.tags, tags: data.tags,
favorite: data.favorite, favorite: data.favorite,
progressFromTasks: data.progressFromTasks,
// Hinten an die Statusspalte des Bretts.
position: await nextProjectPosition(auth.user.id, data.status),
notes: { create: notes },
tasks: { create: tasks },
}, },
select: { id: true }, select: { id: true },
}); });
@@ -109,12 +128,56 @@ export async function POST(request: Request) {
projectId: project.id, projectId: project.id,
userId: auth.user.id, userId: auth.user.id,
kind: "PROJECT_CREATED", kind: "PROJECT_CREATED",
summary: summary: vorlage
data.status === "IDEA" ? `Aus Vorlage „${vorlage.name}“ angelegt`
: data.status === "IDEA"
? "Als Idee angelegt" ? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`, : `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status }, meta: {
status: data.status,
...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}),
},
}); });
return NextResponse.json({ project }, { status: 201 }); return NextResponse.json({ project }, { status: 201 });
} }
/**
* Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript
* unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen
* Eintrag auslassen als am Anlegen scheitern.
*/
function templateNotes(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0)
.slice(0, 50)
.map((entry) => ({
title: typeof entry.title === "string" ? entry.title : null,
content: entry.content as string,
pinned: entry.pinned === true,
}));
}
function templateTasks(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
.filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0)
.slice(0, 100)
.map((entry, index) => ({
title: entry.title as string,
description: typeof entry.description === "string" ? entry.description : null,
status: (["TODO", "DOING", "BLOCKED"] as const).includes(
entry.status as "TODO" | "DOING" | "BLOCKED",
)
? (entry.status as "TODO" | "DOING" | "BLOCKED")
: ("TODO" as const),
// Reihenfolge wie in der Vorlage.
position: index,
labels: Array.isArray(entry.labels)
? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8)
: [],
}));
}
@@ -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);
}
}
+39 -4
View File
@@ -4,7 +4,13 @@ import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { jsonError, readJsonBody } from "@/lib/security/request";
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks"; import {
TASK_STATUS_META,
nextPosition,
refreshDerivedProgress,
spawnRecurrence,
} 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";
@@ -44,6 +50,7 @@ export async function PATCH(request: Request, { params }: Params) {
if (data.title !== undefined) update.title = data.title; if (data.title !== undefined) update.title = data.title;
if (data.description !== undefined) update.description = data.description; if (data.description !== undefined) update.description = data.description;
if (data.labels !== undefined) update.labels = data.labels; if (data.labels !== undefined) update.labels = data.labels;
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
if (data.dueDate !== undefined) { if (data.dueDate !== undefined) {
update.dueDate = data.dueDate ? new Date(data.dueDate) : null; update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
} }
@@ -57,8 +64,34 @@ export async function PATCH(request: Request, { params }: Params) {
} }
const task = await prisma.task.update({ where: { id }, data: update }); const task = await prisma.task.update({ where: { id }, data: update });
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
const folge =
update.status === "DONE" && existing.status !== "DONE"
? await spawnRecurrence(id)
: null;
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.
@@ -68,12 +101,14 @@ export async function PATCH(request: Request, { params }: Params) {
projectId: existing.projectId, projectId: existing.projectId,
userId: auth.user.id, userId: auth.user.id,
kind: "TASK_MOVED", kind: "TASK_MOVED",
summary: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`, summary: folge
meta: { taskId: id, from: existing.status, to: nach }, ? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
}); });
} }
return NextResponse.json({ task }); return NextResponse.json({ task, next: folge });
} }
export async function DELETE(_request: Request, { params }: Params) { export async function DELETE(_request: Request, { params }: Params) {
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, templateUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const existing = await prisma.projectTemplate.findFirst({
where: { id, ownerId: auth.user.id },
select: { id: true },
});
if (!existing) return jsonError("Vorlage nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const data = parsed.data;
if (data.name) {
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name, id: { not: id } },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
}
const template = await prisma.projectTemplate.update({ where: { id }, data });
return NextResponse.json({ template });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst.
const result = await prisma.projectTemplate.deleteMany({
where: { id, ownerId: auth.user.id },
});
if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, templateFromProjectSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Aus einem vorhandenen Projekt eine Vorlage machen.
*
* Das ist der Weg, auf dem Vorlagen ueblicherweise entstehen: man richtet ein
* Projekt so ein, wie es passt, und haelt das dann fest. Eine Vorlage von
* Grund auf zu tippen kann man auch – ueber POST /api/templates.
*
* Nicht mitgenommen wird alles Projektbezogene: Name, Slug, Fortschritt,
* Favorit und vor allem die Repositories. Eine Vorlage, die jedes neue
* Projekt an dasselbe Repository haengt, waere eine Falle.
*/
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateFromProjectSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { projectId, name, description, withNotes, withTasks } = parsed.data;
const project = await prisma.project.findFirst({
where: { id: projectId, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
},
});
if (!project) return jsonError("Projekt nicht gefunden", 404);
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name,
description: description ?? null,
summary: project.summary,
projectDescription: project.description,
status: project.status,
priority: project.priority,
accent: project.accent,
tags: project.tags,
progressFromTasks: project.progressFromTasks,
notes: withNotes
? project.notes.map((note) => ({
title: note.title,
content: note.content,
pinned: note.pinned,
}))
: [],
// Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu
// tun ist, nicht was einmal getan wurde.
tasks: withTasks
? project.tasks
.filter((task) => task.status !== "DONE")
.map((task) => ({
title: task.title,
description: task.description,
status: task.status,
labels: task.labels,
}))
: [],
},
});
return NextResponse.json({ template }, { status: 201 });
}
+80
View File
@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, templateCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/** Hoechstzahl je Konto. Wer mehr braucht, hat keine Vorlagen mehr, sondern Projekte. */
const MAX_TEMPLATES = 30;
export async function GET() {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const templates = await prisma.projectTemplate.findMany({
where: { ownerId: auth.user.id },
orderBy: { name: "asc" },
});
return NextResponse.json({ templates });
}
export async function POST(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = templateCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const vorhanden = await prisma.projectTemplate.count({ where: { ownerId: auth.user.id } });
if (vorhanden >= MAX_TEMPLATES) {
return jsonError(`Mehr als ${MAX_TEMPLATES} Vorlagen sind nicht vorgesehen.`, 409);
}
const data = parsed.data;
// Der Name ist je Konto eindeutig; ein Duplikat soll eine verstaendliche
// Meldung geben statt eines Datenbankfehlers.
const doppelt = await prisma.projectTemplate.findFirst({
where: { ownerId: auth.user.id, name: data.name },
select: { id: true },
});
if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409);
const template = await prisma.projectTemplate.create({
data: {
ownerId: auth.user.id,
name: data.name,
description: data.description ?? null,
summary: data.summary ?? null,
projectDescription: data.projectDescription ?? null,
status: data.status,
priority: data.priority,
accent: data.accent,
tags: data.tags,
progressFromTasks: data.progressFromTasks,
notes: data.notes,
tasks: data.tasks,
},
});
return NextResponse.json({ template }, { status: 201 });
}
@@ -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 });
}
+70
View File
@@ -124,6 +124,30 @@
} }
.md a:hover { color: #ddd6fe; } .md a:hover { color: #ddd6fe; }
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
bekommt sie einen Rahmen und ein Symbol davor. */
.md .md-image {
display: inline-flex;
align-items: center;
gap: 0.3em;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: 0.4rem;
padding: 0.05em 0.45em;
font-size: 0.85em;
color: #8a98b0;
text-decoration: none;
vertical-align: baseline;
}
.md .md-image::before {
content: "▣";
opacity: 0.6;
}
.md a.md-image:hover {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.4);
}
.md strong { color: #f1f5f9; font-weight: 600; } .md strong { color: #f1f5f9; font-weight: 600; }
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; } .md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
@@ -253,6 +277,21 @@
border: 1px solid transparent; border: 1px solid transparent;
} }
/**
* Auf Zeigergeraeten reichen die 36px, die sich aus Schriftgroesse und
* Innenabstand ergeben. Ein Finger trifft das nicht zuverlaessig – dort
* gilt die uebliche Untergrenze von 44px.
*
* Ueber "pointer: coarse" statt einer Breitenabfrage: entscheidend ist das
* Eingabegeraet, nicht die Fenstergroesse. Ein Tablet im Querformat ist
* breit und wird trotzdem mit dem Finger bedient.
*/
@media (pointer: coarse) {
.btn {
min-height: 2.75rem;
}
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -311,6 +350,19 @@
box-shadow 0.2s ease; box-shadow 0.2s ease;
} }
/**
* Unter 16px zoomt iOS Safari beim Hineintippen die ganze Seite heran und
* kehrt danach nicht von selbst zurueck – aus einem Formular wird so eine
* Schiebepartie. Deshalb auf Beruehrungsgeraeten volle 16px, unabhaengig
* von der Fensterbreite.
*/
@media (pointer: coarse) {
.field {
font-size: 1rem;
padding: 0.65rem 0.85rem;
}
}
.field::placeholder { .field::placeholder {
color: rgba(148, 163, 184, 0.6); color: rgba(148, 163, 184, 0.6);
} }
@@ -392,4 +444,22 @@
.range::-moz-range-track { .range::-moz-range-track {
background: transparent; background: transparent;
} }
/* Ein 16px-Griff ist mit dem Finger kaum zu fassen. Die Spur bleibt
schmal, damit der Regler nicht ploetzlich klobig wirkt. */
@media (pointer: coarse) {
.range {
height: 10px;
}
.range::-webkit-slider-thumb {
height: 24px;
width: 24px;
}
.range::-moz-range-thumb {
height: 22px;
width: 22px;
}
}
} }
+30
View File
@@ -16,12 +16,35 @@
--color-void-soft: #0a0c1a; --color-void-soft: #0a0c1a;
--color-haze: #12142b; --color-haze: #12142b;
/* ── Gedaempfte Textfarben ──────────────────────────────────
Tailwinds slate-500 und slate-600 kommen auf diesem sehr dunklen
Untergrund nur auf 3,8 bzw. 2,5 zu 1. WCAG AA verlangt 4,5 fuer
normalen Text, und beide werden hier reichlich fuer Hinweise und
Nebenangaben benutzt – axe-core hat das an 26 Stellen beanstandet.
Statt an hundert Stellen die Klasse zu tauschen, werden die beiden
Stufen hier aufgehellt. Der Abstand zwischen ihnen bleibt sichtbar,
der Rest des Entwurfs unangetastet.
Geprueft gegen die drei vorkommenden Untergruende #0b0d1c (Seite),
#0e0f17 (.glass) und #14141d (.popover) – der schlechteste Wert
zaehlt. */
--color-slate-500: #8a98b0; /* 6,3 : 1 */
--color-slate-600: #78879f; /* 5,0 : 1 */
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif; "Helvetica Neue", Arial, "Noto Sans", sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, --font-mono: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo,
Consolas, "DejaVu Sans Mono", monospace; Consolas, "DejaVu Sans Mono", monospace;
--radius-card: 1.25rem; --radius-card: 1.25rem;
/* Eine Stufe unterhalb von Tailwinds "sm" (40rem). Zwischen 320px und
640px liegt der gesamte Telefonbereich, und dort ist der Unterschied
zwischen "Beschriftung passt noch" und "passt nicht mehr" oft nur ein
Zentimeter breit. 22rem trennt die wirklich schmalen Geraete (iPhone SE
der ersten Reihe, 320px) vom Rest, der bei 360px und mehr anfaengt. */
--breakpoint-xs: 22rem;
} }
:root { :root {
@@ -47,7 +70,14 @@
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
/* Ein Notnagel gegen seitliches Scrollen, kein Ersatz fuer richtiges
Umbrechen: was hier abgeschnitten wird, ist auf dem Telefon einfach
nicht mehr erreichbar. Die Ursachen gehoeren in die Komponenten. */
overflow-x: hidden; overflow-x: hidden;
/* Mit viewportFit: "cover" reicht die Seite bis in die Ecken; ohne diese
Einrueckung stuende der Inhalt im Querformat unter der Kameraleiste. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
} }
::selection { ::selection {
+21 -1
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { ChangelogButton } from "@/components/ChangelogButton";
import { CommandPalette } from "@/components/CommandPalette"; import { CommandPalette } from "@/components/CommandPalette";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { getSessionUser } from "@/lib/auth/session"; import { getSessionUser } from "@/lib/auth/session";
@@ -20,6 +21,20 @@ export async function generateMetadata(): Promise<Metadata> {
"Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.", "Uebersicht ueber alle Vibe-Coding Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
applicationName: APP_NAME, applicationName: APP_NAME,
robots: { index: false, follow: false }, robots: { index: false, follow: false },
manifest: "/manifest.webmanifest",
// iOS liest das Manifest nicht; dort braucht es diese Angaben einzeln.
appleWebApp: { capable: true, title: APP_NAME, statusBarStyle: "black-translucent" },
// Beide Eintraege ausdruecklich: sobald hier etwas steht, ersetzt es den
// Verweis, den Next aus app/icon.svg von selbst erzeugt. Ohne die erste
// Zeile haette die Seite gar kein Symbol mehr und der Browser fiele auf
// /favicon.ico zurueck – das es hier nicht gibt.
icons: {
icon: [
{ url: "/icon.svg", type: "image/svg+xml" },
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
],
apple: "/apple-touch-icon.png",
},
}; };
} }
@@ -27,6 +42,11 @@ export const viewport: Viewport = {
themeColor: "#05060f", themeColor: "#05060f",
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
// Die Seite reicht bis unter Statusleiste und Home-Anzeige; das ist noetig,
// damit der Hintergrund im installierten Betrieb nicht an einem weissen
// Balken endet. Was dadurch verdeckt wuerde, holen die env(safe-area-*)
// in globals.css wieder heraus.
viewportFit: "cover",
}; };
export default async function RootLayout({ export default async function RootLayout({
@@ -56,7 +76,7 @@ export default async function RootLayout({
<footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8"> <footer className="mx-auto w-full max-w-[1400px] px-4 pb-10 text-xs text-slate-500 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6"> <div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/5 pt-6">
<span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span> <span>{APP_NAME} · lokal betrieben, Daten bleiben in deiner PostgreSQL.</span>
<span className="font-mono text-slate-600">v{APP_VERSION}</span> <ChangelogButton version={APP_VERSION} />
</div> </div>
</footer> </footer>
</body> </body>
+41
View File
@@ -0,0 +1,41 @@
import type { MetadataRoute } from "next";
import { APP_NAME, APP_TAGLINE } from "@/lib/config";
/**
* Web-App-Manifest: macht Nebula auf dem Handy installierbar.
*
* Als Funktion und nicht als Konstante, aus demselben Grund wie bei
* generateMetadata: nur so wird APP_NAME zur Laufzeit gelesen. Ein
* konstanter Export waere beim Build eingefroren und liesse sich im
* fertigen Docker-Image nicht mehr aendern.
*/
export const dynamic = "force-dynamic";
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${APP_NAME} – ${APP_TAGLINE} Dashboard`,
short_name: APP_NAME,
description:
"Uebersicht ueber alle Vibe-Coding-Projekte: Status, Notizen und Live-Daten aus GitHub, GitLab und Gitea.",
// Ohne Adressleiste, aber mit den Systemleisten – die Anwendung braucht
// keinen Vollbildmodus, wohl aber die Uhrzeit und den Akkustand.
display: "standalone",
start_url: "/",
scope: "/",
background_color: "#05060f",
theme_color: "#05060f",
lang: "de",
orientation: "any",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// Getrennte Fassung mit mehr Rand: Android schneidet maskierbare
// Symbole je nach Geraet rund, eckig oder als Tropfen zu.
{ src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
shortcuts: [
{ name: "Dashboard", url: "/" },
{ name: "Mein Konto", url: "/account" },
],
};
}
+16 -2
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,15 +51,28 @@ 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>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> {/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
Kacheln dort den ganzen ersten Bildschirm, und die Projekte – der
eigentliche Inhalt – begannen erst nach dem zweiten Wischen. */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
<StatCard <StatCard
label="Projekte" label="Projekte"
value={active.length} value={active.length}
hint={`Ø Fortschritt ${averageProgress}%`} hint={`Ø Fortschritt ${averageProgress}%`}
icon={CircleDot} icon={CircleDot}
color="#a78bfa" color="#a78bfa"
className="col-span-2 lg:col-span-1"
/> />
<StatCard <StatCard
label="Ideen" label="Ideen"
+41 -3
View File
@@ -5,12 +5,13 @@ import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel"; import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ReadmePanel } from "@/components/ReadmePanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard"; 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";
@@ -54,6 +55,23 @@ 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 readme = fuehrend?.readme
? {
content: fuehrend.readme,
path: fuehrend.readmePath,
baseUrl: fuehrend.readmeBaseUrl,
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
fileUrl:
fuehrend.readmeBaseUrl && fuehrend.readmePath
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
: (fuehrend.htmlUrl ?? null),
}
: null;
return ( return (
<div className="space-y-6 pt-4"> <div className="space-y-6 pt-4">
<ProjectHeader <ProjectHeader
@@ -67,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" />
@@ -83,13 +109,25 @@ export default async function ProjectPage({ params }: PageProps) {
)} )}
</section> </section>
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
{readme ? (
<ReadmePanel
content={readme.content}
path={readme.path}
baseUrl={readme.baseUrl}
fileUrl={readme.fileUrl}
/>
) : null}
<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>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { TaskOverview } from "@/components/tasks/TaskOverview";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { dueBucket } from "@/lib/tasks";
import type { TaskOverviewItem } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Aufgaben" };
export default async function TasksPage() {
const user = await requireUserPage();
/**
* Die Aufgabe kennt keinen Besitzer – die Trennung laeuft ueber das
* Projekt. Die Bedingung steht deshalb als einzige und erste in der
* where-Klausel; eine fremde Aufgabe kann hier gar nicht auftauchen.
*/
const tasks = await prisma.task.findMany({
where: { project: { ownerId: user.id } },
include: {
project: { select: { id: true, name: true, accent: true, status: true } },
},
// NULL-Termine sortiert Postgres bei ASC ans Ende – genau richtig,
// Aufgaben ohne Termin gehoeren nicht nach oben.
orderBy: [{ dueDate: "asc" }, { position: "asc" }],
});
/**
* Das Fach wird auf dem Server bestimmt, nicht im Browser. "Heute" haengt
* an der Uhr, und zwei verschiedene Antworten zwischen Server-Render und
* Hydration waeren genau der Fehler, den die feste Zeitzone in lib/utils
* sonst vermeidet.
*/
const jetzt = new Date();
const items: TaskOverviewItem[] = tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
status: task.status,
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
labels: task.labels,
recurrence: task.recurrence,
sourceIssue: task.sourceIssue,
sourceUrl: task.sourceUrl,
bucket: dueBucket(task.dueDate, jetzt),
project: task.project,
}));
return <TaskOverview tasks={items} />;
}
+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>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Star, Tag, Trash2, X } from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { cn, parseTags } from "@/lib/utils";
/**
* Leiste fuer die Mehrfachauswahl. Erscheint erst, wenn etwas ausgewaehlt ist,
* und liegt fest am unteren Rand – bei einer langen Liste waere sie oben
* sonst laengst aus dem Blick gescrollt, waehrend man noch auswaehlt.
*/
export function BulkBar({
ids,
onDone,
onClear,
}: {
ids: string[];
onDone: () => void;
onClear: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [tagsOpen, setTagsOpen] = useState(false);
const [tagInput, setTagInput] = useState("");
const statusRef = useRef<HTMLButtonElement>(null);
const tagsRef = useRef<HTMLButtonElement>(null);
async function apply(body: Record<string, unknown>) {
setBusy(true);
setError(null);
try {
const response = await fetch("/api/projects/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, ...body }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt.");
onDone();
router.refresh();
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
setStatusOpen(false);
setTagsOpen(false);
}
}
async function remove() {
if (
!window.confirm(
`${ids.length} Projekt${ids.length === 1 ? "" : "e"} mit allen Notizen und Aufgaben ` +
"unwiderruflich loeschen?",
)
) {
return;
}
await apply({ action: "delete" });
}
return (
// Der Abstand nach unten beruecksichtigt die Home-Anzeige des iPhones;
// ohne das liegt die Leiste im installierten Betrieb darunter.
<div
className="pointer-events-none fixed inset-x-0 bottom-4 z-[90] flex justify-center px-3"
style={{ bottom: "calc(1rem + env(safe-area-inset-bottom))" }}
>
<div className="popover pointer-events-auto flex max-w-full flex-wrap items-center justify-center gap-2 px-3 py-2 shadow-2xl">
<span className="px-1 text-sm tabular-nums text-slate-200">
{ids.length} ausgewählt
</span>
<span className="h-5 w-px bg-white/10" aria-hidden />
<button
ref={statusRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setStatusOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={statusOpen}
>
Status
</button>
<Popover
open={statusOpen}
anchorRef={statusRef}
onClose={() => setStatusOpen(false)}
align="start"
>
<div role="menu" className="min-w-[12rem] p-1">
{STATUS_ORDER.map((status) => (
<button
key={status}
type="button"
role="menuitem"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-slate-300 hover:bg-white/6"
onClick={() => apply({ action: "status", status })}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_META[status].color }}
/>
{STATUS_META[status].label}
</button>
))}
</div>
</Popover>
<button
ref={tagsRef}
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTagsOpen((value) => !value)}
disabled={busy}
aria-haspopup="menu"
aria-expanded={tagsOpen}
>
<Tag className="h-3.5 w-3.5" />
Tags
</button>
{/* Die Breite gehoert an den Popover, nicht an sein Kind: der Popover
setzt overflow-hidden, ein breiteres Kind wird schlicht
abgeschnitten – frueher um die Haelfte des Abbrechen-Knopfs. */}
<Popover
open={tagsOpen}
anchorRef={tagsRef}
onClose={() => setTagsOpen(false)}
align="start"
width={288}
>
<div className="space-y-2 p-3">
<label className="field-label" htmlFor="bulk-tags">
Tags (kommagetrennt)
</label>
<input
id="bulk-tags"
className="field"
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
placeholder="experiment, archiv"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
className="btn btn-primary flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "add" })
}
>
Hinzufügen
</button>
<button
type="button"
className="btn btn-ghost flex-1 px-2 py-1.5 text-xs"
disabled={busy || parseTags(tagInput).length === 0}
onClick={() =>
apply({ action: "tags", tags: parseTags(tagInput), tagMode: "remove" })
}
>
Entfernen
</button>
</div>
</div>
</Popover>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => apply({ action: "favorite", favorite: true })}
disabled={busy}
title="Als Favoriten markieren"
>
<Star className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={remove}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
Löschen
</button>
{busy ? <Loader2 className="h-4 w-4 animate-spin text-slate-400" /> : null}
<button
type="button"
onClick={onClear}
className="rounded-lg p-1.5 text-slate-500 hover:text-white"
aria-label="Auswahl aufheben"
>
<X className="h-4 w-4" />
</button>
{error ? (
<p className={cn("w-full px-1 text-xs text-rose-300")}>{error}</p>
) : null}
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { CHANGELOG, KIND_META } from "@/lib/changelog";
import { cn, formatDate } from "@/lib/utils";
/**
* Die Versionsnummer in der Fusszeile oeffnet den Aenderungsverlauf.
*
* Kein eigener Menuepunkt: gesucht wird so etwas erfahrungsgemaess dort, wo
* die Version steht – und einen Platz in der Navigation ist es nicht wert.
*/
export function ChangelogButton({ version }: { version: string }) {
const [open, setOpen] = useState(false);
const aktuell = CHANGELOG[0]?.version;
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded font-mono text-slate-600 underline decoration-dotted underline-offset-4 transition-colors hover:text-violet-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400/60"
title="Was ist neu?"
>
v{version}
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
width="lg"
title="Änderungsverlauf"
description={
// Ein Hinweis, falls die laufende Fassung neuer ist als der Eintrag.
// Das passiert, wenn jemand die Version hochzaehlt und den Eintrag
// vergisst – dann soll es auffallen statt still danebenzustehen.
aktuell === version
? "Was in den letzten Fassungen dazugekommen ist."
: `Diese Instanz läuft auf v${version}; der Verlauf endet bei v${aktuell}.`
}
>
<ol className="space-y-7">
{CHANGELOG.map((release) => (
<li key={release.version}>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h3 className="font-mono text-base font-semibold text-white">
v{release.version}
</h3>
{release.version === version ? (
<span className="rounded-full bg-violet-500/15 px-2 py-0.5 text-[10px] uppercase tracking-wider text-violet-200 ring-1 ring-violet-400/30">
installiert
</span>
) : null}
<span className="text-xs text-slate-500">{formatDate(release.date)}</span>
</div>
<p className="mt-1 text-sm text-slate-400">{release.headline}</p>
<ul className="mt-3 space-y-2">
{release.changes.map((change, index) => {
const meta = KIND_META[change.kind];
return (
<li key={index} className="flex gap-2.5 text-sm leading-snug text-slate-300">
<span
className={cn(
"mt-0.5 h-fit shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ring-1",
meta.className,
)}
>
{meta.label}
</span>
<span className="min-w-0">{change.text}</span>
</li>
);
})}
</ul>
</li>
))}
</ol>
</Modal>
</>
);
}
+72 -5
View File
@@ -5,8 +5,11 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Bell, Bell,
CalendarRange,
CheckSquare,
CornerDownLeft, CornerDownLeft,
GitBranch, GitBranch,
History,
LayoutDashboard, LayoutDashboard,
Loader2, Loader2,
NotebookPen, NotebookPen,
@@ -15,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";
/** /**
@@ -37,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;
@@ -55,6 +67,27 @@ 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",
label: "Aufgaben",
hint: "Über alle Projekte, nach Fälligkeit",
icon: CheckSquare,
href: "/tasks",
},
{ {
id: "connections", id: "connections",
label: "Git-Verbindungen", label: "Git-Verbindungen",
@@ -83,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();
@@ -92,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);
@@ -130,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();
@@ -139,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.
} }
@@ -155,6 +192,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
if (!open) { if (!open) {
setQuery(""); setQuery("");
setNotes([]); setNotes([]);
setAufgaben([]);
setActive(0); setActive(0);
} }
}, [open]); }, [open]);
@@ -194,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",
@@ -207,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)));
@@ -218,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],
@@ -268,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}
@@ -322,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" />
+42 -1
View File
@@ -17,6 +17,7 @@ import {
Lock, Lock,
Package, Package,
Plus, Plus,
Radio,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
Star, Star,
@@ -24,10 +25,12 @@ import {
Unlink, Unlink,
} from "lucide-react"; } from "lucide-react";
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
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, asRecord } from "@/types"; import { asArray, asCalendar, asRecord } from "@/types";
import type { import type {
BranchInfo, BranchInfo,
CommitInfo, CommitInfo,
@@ -54,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");
@@ -64,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);
@@ -71,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() {
@@ -109,6 +118,7 @@ export function GitPanel({
const pulls = asArray<TicketInfo>(snapshot?.pullRequests); const pulls = asArray<TicketInfo>(snapshot?.pullRequests);
const issues = asArray<TicketInfo>(snapshot?.issues); const issues = asArray<TicketInfo>(snapshot?.issues);
const activity = asArray<number>(snapshot?.commitActivity); const activity = asArray<number>(snapshot?.commitActivity);
const calendar = asCalendar(snapshot?.commitCalendar);
/** Ein einzelnes Repository oder – ohne Argument – alle. */ /** Ein einzelnes Repository oder – ohne Argument – alle. */
async function sync(nurDieses?: string) { async function sync(nurDieses?: string) {
@@ -278,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)}
@@ -310,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(
@@ -416,6 +454,9 @@ export function GitPanel({
</div> </div>
) : null} ) : null}
{/* Der Jahresueberblick, sobald er einmal geholt wurde. */}
{calendar ? <Heatmap calendar={calendar} /> : null}
{/* Sprachen */} {/* Sprachen */}
{languageTotal > 0 ? ( {languageTotal > 0 ? (
<div className="glass p-4"> <div className="glass p-4">
+190
View File
@@ -0,0 +1,190 @@
import { CalendarRange } from "lucide-react";
import type { CommitCalendar } from "@/lib/git/calendar";
import { formatDate } from "@/lib/utils";
/**
* Commits je Tag als Kalender – ein Feld pro Tag, Wochen als Spalten.
*
* Wie die Verlaufslinie ohne Diagrammbibliothek: es sind Quadrate in einem
* Raster, dafuer braucht es keine Achsen und keine Legende ausser der
* Farbskala.
*
* Gezeichnet wird nur der Zeitraum, fuer den wirklich Daten vorliegen.
* Wo die Seiten des Anbieters nur ein paar Monate zurueckreichen, faengt die
* Heatmap dort an – ein leeres Feld wuerde sonst behaupten, an dem Tag sei
* nicht gearbeitet worden, obwohl darueber nichts bekannt ist.
*/
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
const MONATE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
/** Fuenf Stufen, damit ein einzelner Ausreisser nicht alles andere platt macht. */
function stufe(anzahl: number, hoch: number): number {
if (anzahl <= 0) return 0;
if (hoch <= 1) return 4;
const anteil = anzahl / hoch;
if (anteil > 0.75) return 4;
if (anteil > 0.5) return 3;
if (anteil > 0.25) return 2;
return 1;
}
const FARBEN = [
"fill-white/6",
"fill-violet-500/25",
"fill-violet-500/45",
"fill-violet-400/70",
"fill-violet-300",
];
/** Montag der Woche, in der dieser Tag liegt (UTC). */
function wochenanfang(tag: Date): Date {
const kopie = new Date(tag);
// getUTCDay: 0 = Sonntag. Die Woche soll am Montag beginnen.
const versatz = (kopie.getUTCDay() + 6) % 7;
kopie.setUTCDate(kopie.getUTCDate() - versatz);
kopie.setUTCHours(0, 0, 0, 0);
return kopie;
}
export function Heatmap({ calendar }: { calendar: CommitCalendar }) {
const von = new Date(`${calendar.from}T00:00:00.000Z`);
const bis = new Date(`${calendar.to}T00:00:00.000Z`);
if (Number.isNaN(von.getTime()) || Number.isNaN(bis.getTime()) || von > bis) return null;
const start = wochenanfang(von);
const wochen: Array<Array<{ key: string; anzahl: number; ausserhalb: boolean }>> = [];
let hoechstwert = 0;
for (const wert of Object.values(calendar.days)) {
if (wert > hoechstwert) hoechstwert = wert;
}
const zeiger = new Date(start);
while (zeiger <= bis) {
const woche: Array<{ key: string; anzahl: number; ausserhalb: boolean }> = [];
for (let tag = 0; tag < 7; tag += 1) {
const key = zeiger.toISOString().slice(0, 10);
// Vor dem ersten bekannten Tag und nach heute wird nichts gezeichnet.
const ausserhalb = zeiger < von || zeiger > bis;
woche.push({ key, anzahl: calendar.days[key] ?? 0, ausserhalb });
zeiger.setUTCDate(zeiger.getUTCDate() + 1);
}
wochen.push(woche);
}
const gesamt = Object.values(calendar.days).reduce((sum, wert) => sum + wert, 0);
const tageMitCommits = Object.keys(calendar.days).length;
// Masse in SVG-Einheiten; die Breite ergibt sich aus der Zahl der Wochen.
const feld = 11;
const luecke = 3;
const kopf = 14;
const spalte = feld + luecke;
const breite = wochen.length * spalte - luecke;
const hoehe = kopf + 7 * spalte - luecke;
// Monatsbeschriftung an der Woche, in der ein neuer Monat beginnt.
const monate: Array<{ x: number; label: string }> = [];
let letzterMonat = -1;
wochen.forEach((woche, index) => {
const ersterTag = new Date(`${woche[0].key}T00:00:00.000Z`);
const monat = ersterTag.getUTCMonth();
if (monat !== letzterMonat) {
letzterMonat = monat;
monate.push({ x: index * spalte, label: MONATE[monat] });
}
});
return (
<div className="glass p-4">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
<CalendarRange className="h-3 w-3" />
Commits je Tag
</p>
<p className="text-[11px] text-slate-500">
{gesamt} an {tageMitCommits} {tageMitCommits === 1 ? "Tag" : "Tagen"} ·{" "}
{/* Der Zeitraum gehoert dazu: er ist nicht immer ein Jahr. */}
seit {formatDate(calendar.from)}
{calendar.truncated ? " (Älteres nicht abgerufen)" : ""}
</p>
</div>
<div className="-mx-1 overflow-x-auto px-1">
{/*
Kein max-w-full: zusammen mit der festen Hoehe staucht das die
Wochen auf schmalen Anzeigen zu einem unlesbaren Streifen, statt
den Elter seitlich scrollen zu lassen. Die Kalenderfelder haben
eine feste Groesse – die Karte gehoert geschoben, nicht gequetscht.
*/}
<svg
viewBox={`0 0 ${breite + 22} ${hoehe}`}
width={breite + 22}
height={hoehe}
className="block shrink-0"
role="img"
aria-label={`Commits je Tag seit ${formatDate(calendar.from)}: ${gesamt} an ${tageMitCommits} Tagen`}
>
{monate.map((monat, index) => (
<text
key={`${monat.label}-${index}`}
x={monat.x + 22}
y={9}
className="fill-slate-500 text-[8px]"
>
{monat.label}
</text>
))}
{/* Nur jeder zweite Wochentag, sonst steht es zu eng. */}
{WOCHENTAGE.map((tag, index) =>
index % 2 === 1 ? (
<text
key={tag}
x={0}
y={kopf + index * spalte + feld - 2}
className="fill-slate-600 text-[7px]"
>
{tag}
</text>
) : null,
)}
{wochen.map((woche, x) =>
woche.map((tag, y) =>
tag.ausserhalb ? null : (
<rect
key={tag.key}
x={x * spalte + 22}
y={kopf + y * spalte}
width={feld}
height={feld}
rx={2}
className={FARBEN[stufe(tag.anzahl, hoechstwert)]}
>
<title>
{`${formatDate(tag.key)}: ${tag.anzahl} ${tag.anzahl === 1 ? "Commit" : "Commits"}`}
</title>
</rect>
),
),
)}
</svg>
</div>
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] text-slate-600">
<span>weniger</span>
{FARBEN.map((farbe, index) => (
<svg key={index} width={9} height={9} aria-hidden>
<rect width={9} height={9} rx={2} className={farbe} />
</svg>
))}
<span>mehr</span>
</div>
</div>
);
}
+183 -67
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@@ -8,53 +8,81 @@ import {
DragOverlay, DragOverlay,
PointerSensor, PointerSensor,
TouchSensor, TouchSensor,
closestCorners, pointerWithin,
useDraggable,
useDroppable, useDroppable,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
type DragOverEvent,
type DragStartEvent, type DragStartEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { restrictToWindowEdges } from "@dnd-kit/modifiers"; import { restrictToWindowEdges } from "@dnd-kit/modifiers";
import { GitBranch, GripVertical, NotebookPen } from "lucide-react"; import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GitBranch, GripVertical, Loader2, NotebookPen } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { CiBadge } from "@/components/CiBadge"; import { CiBadge } from "@/components/CiBadge";
import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status"; import { STATUS_META, STATUS_ORDER, accentOf } from "@/lib/status";
import { cn, truncate } from "@/lib/utils"; import { cn, truncate } from "@/lib/utils";
import type { ProjectListItem } from "@/types"; import type { ProjectListItem } from "@/types";
type Columns = Record<ProjectStatus, ProjectListItem[]>;
/** /**
* Statusspalten mit Ziehen und Ablegen. * Statusspalten mit Ziehen, Ablegen und freier Reihenfolge.
* *
* Der Statuswechsel wird sofort angezeigt und erst danach gespeichert; * Die Reihenfolge innerhalb einer Spalte wird als Position am Projekt
* scheitert das Speichern, springt die Karte zurueck. Die Reihenfolge * gespeichert – wie beim Aufgabenbrett. Deshalb richtet sich das Brett auch
* innerhalb einer Spalte ist nicht frei sortierbar – sie folgt der im Board * nicht nach der im Kopf gewaehlten Sortierung: die gilt fuer Raster und
* gewaehlten Sortierung, sonst muesste eine Position mitgespeichert werden. * Gruppen, hier zaehlt die Anordnung von Hand.
*
* Verschieben wird sofort angezeigt und erst danach gespeichert; scheitert
* das Speichern, springt alles auf den Serverstand zurueck.
*/ */
export function KanbanBoard({ export function KanbanBoard({
projects, projects,
showArchived, showArchived,
selection,
onToggleSelect,
}: { }: {
projects: ProjectListItem[]; projects: ProjectListItem[];
showArchived: boolean; showArchived: boolean;
selection: Set<string>;
onToggleSelect: (id: string, selected: boolean) => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const [overrides, setOverrides] = useState<Record<string, ProjectStatus>>({});
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Eine Verschiebung, die der Server bestaetigt hat, braucht keine const sichtbar = useMemo(
// oertliche Ausnahme mehr. () => STATUS_ORDER.filter((status) => showArchived || status !== "ARCHIVED"),
useEffect(() => { [showArchived],
setOverrides((current) => { );
const next: Record<string, ProjectStatus> = {};
for (const [id, status] of Object.entries(current)) { const group = useMemo(
const project = projects.find((entry) => entry.id === id); () => (list: ProjectListItem[]): Columns => {
if (project && project.status !== status) next[id] = status; const leer = Object.fromEntries(
STATUS_ORDER.map((status) => [status, [] as ProjectListItem[]]),
) as Columns;
for (const project of list) leer[project.status].push(project);
for (const status of STATUS_ORDER) {
// Bei gleicher Position bleibt es bei der Reihenfolge, in der die
// Liste kam – das ist die im Kopf gewaehlte Sortierung.
leer[status].sort((a, b) => a.position - b.position);
} }
return Object.keys(next).length === Object.keys(current).length ? current : next; return leer;
}); },
}, [projects]); [],
);
const [columns, setColumns] = useState<Columns>(() => group(projects));
// Waehrend eines Zuges nicht dazwischenfunken: der Serverstand ist dann
// noch der alte und wuerde die Karte zurueckspringen lassen.
const dragging = useRef(false);
useEffect(() => {
if (!dragging.current) setColumns(group(projects));
}, [projects, group]);
const sensors = useSensors( const sensors = useSensors(
// Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte // Erst ab ein paar Pixeln Bewegung ziehen – sonst liesse sich die Karte
@@ -63,72 +91,138 @@ export function KanbanBoard({
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }),
); );
const statusOf = (project: ProjectListItem) => overrides[project.id] ?? project.status; const active = activeId
? Object.values(columns).flat().find((project) => project.id === activeId) ?? null
: null;
const columns = useMemo(() => { /** In welcher Spalte steckt diese Kennung – oder ist sie selbst eine? */
const visible = STATUS_ORDER.filter( function containerOf(key: string): ProjectStatus | null {
(status) => showArchived || status !== "ARCHIVED", if ((STATUS_ORDER as string[]).includes(key)) return key as ProjectStatus;
return (
STATUS_ORDER.find((status) => columns[status].some((project) => project.id === key)) ?? null
); );
return visible.map((status) => ({ }
status,
items: projects.filter((project) => statusOf(project) === status),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, overrides, showArchived]);
const active = activeId ? projects.find((project) => project.id === activeId) : null;
function onDragStart(event: DragStartEvent) { function onDragStart(event: DragStartEvent) {
dragging.current = true;
setActiveId(String(event.active.id)); setActiveId(String(event.active.id));
} }
function onDragOver(event: DragOverEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
if (!overKey) return;
const from = containerOf(activeKey);
const to = containerOf(overKey);
if (!from || !to || from === to) return;
setColumns((current) => {
const moved = current[from].find((project) => project.id === activeKey);
if (!moved) return current;
const zielIndex = current[to].findIndex((project) => project.id === overKey);
const einfuegenBei = zielIndex >= 0 ? zielIndex : current[to].length;
return {
...current,
[from]: current[from].filter((project) => project.id !== activeKey),
[to]: [
...current[to].slice(0, einfuegenBei),
{ ...moved, status: to },
...current[to].slice(einfuegenBei),
],
};
});
}
async function onDragEnd(event: DragEndEvent) { async function onDragEnd(event: DragEndEvent) {
const activeKey = String(event.active.id);
const overKey = event.over ? String(event.over.id) : null;
let next = columns;
if (overKey) {
const container = containerOf(activeKey);
const ueber = containerOf(overKey);
if (container && container === ueber) {
const list = columns[container];
const alt = list.findIndex((project) => project.id === activeKey);
const neu = list.findIndex((project) => project.id === overKey);
if (alt >= 0 && neu >= 0 && alt !== neu) {
const sortiert = [...list];
const [moved] = sortiert.splice(alt, 1);
sortiert.splice(neu, 0, moved);
next = { ...columns, [container]: sortiert };
setColumns(next);
}
}
}
setActiveId(null); setActiveId(null);
dragging.current = false;
await persist(next);
}
const id = String(event.active.id); async function persist(next: Columns) {
const target = event.over?.id ? (String(event.over.id) as ProjectStatus) : null; setSaving(true);
if (!target) return;
const project = projects.find((entry) => entry.id === id);
if (!project) return;
const previous = statusOf(project);
if (previous === target) return;
setOverrides((current) => ({ ...current, [id]: target }));
try { try {
const response = await fetch(`/api/projects/${id}`, { const response = await fetch("/api/projects/reorder", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: target }), body: JSON.stringify({
columns: Object.fromEntries(
sichtbar.map((status) => [status, next[status].map((project) => project.id)]),
),
}),
}); });
if (!response.ok) throw new Error("failed"); if (!response.ok) throw new Error("failed");
router.refresh(); router.refresh();
} catch { } catch {
setOverrides((current) => { // Zurueck auf den Serverstand – die Bewegung war dann nur optisch.
const next = { ...current }; setColumns(group(projects));
delete next[id]; } finally {
return next; setSaving(false);
});
} }
} }
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={pointerWithin}
modifiers={[restrictToWindowEdges]} modifiers={[restrictToWindowEdges]}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDragCancel={() => setActiveId(null)} onDragCancel={() => {
setActiveId(null);
dragging.current = false;
setColumns(group(projects));
}}
> >
{saving ? (
<p className="mb-2 flex items-center gap-1.5 text-[11px] text-slate-500">
<Loader2 className="h-3 w-3 animate-spin" />
Reihenfolge wird gespeichert…
</p>
) : null}
<div className="flex gap-3 overflow-x-auto pb-3"> <div className="flex gap-3 overflow-x-auto pb-3">
{columns.map((column) => ( {sichtbar.map((status) => (
<Column key={column.status} status={column.status} count={column.items.length}> <Column key={status} status={status} count={columns[status].length}>
{column.items.map((project) => ( <SortableContext
<KanbanCard key={project.id} project={project} /> items={columns[status].map((project) => project.id)}
))} strategy={verticalListSortingStrategy}
>
{columns[status].map((project) => (
<KanbanCard
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</SortableContext>
</Column> </Column>
))} ))}
</div> </div>
@@ -159,7 +253,11 @@ function Column({
// Eindeutiger Anker fuer Tests und Automatisierung. // Eindeutiger Anker fuer Tests und Automatisierung.
data-column={status} data-column={status}
className={cn( className={cn(
"flex w-[17rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors", // Schmaler auf dem Telefon: bei 17rem stand die zweite Spalte fast
// vollstaendig ausserhalb des Bildschirms, und ohne einen sichtbaren
// Rest der naechsten Spalte sieht man dem Brett nicht an, dass es
// seitlich weitergeht.
"flex w-[14rem] shrink-0 flex-col rounded-2xl border border-white/6 bg-white/2 transition-colors sm:w-[17rem]",
isOver && "border-violet-400/40 bg-violet-500/6", isOver && "border-violet-400/40 bg-violet-500/6",
)} )}
> >
@@ -196,28 +294,36 @@ function Column({
function KanbanCard({ function KanbanCard({
project, project,
overlay = false, overlay = false,
selected = false,
onToggleSelect,
}: { }: {
project: ProjectListItem; project: ProjectListItem;
overlay?: boolean; overlay?: boolean;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) { }) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: project.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: project.id,
});
return ( return (
<article <article
ref={overlay ? undefined : setNodeRef} ref={overlay ? undefined : setNodeRef}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
...(overlay ? {} : { transform: CSS.Transform.toString(transform), transition }),
} as React.CSSProperties
}
className={cn( className={cn(
"popover group relative overflow-hidden p-3", "popover group relative overflow-hidden p-3",
// Das Original bleibt als blasser Platzhalter stehen. // Das Original bleibt als blasser Platzhalter stehen.
isDragging && !overlay && "opacity-30", isDragging && !overlay && "opacity-30",
overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40", overlay && "rotate-2 cursor-grabbing shadow-2xl ring-1 ring-violet-400/40",
selected && "ring-1 ring-violet-400/60",
)} )}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
> >
<span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" /> <span className="accent-bar absolute inset-x-0 top-0 h-[2px] opacity-70" aria-hidden="true" />
@@ -233,6 +339,16 @@ function KanbanCard({
<GripVertical className="h-3.5 w-3.5" /> <GripVertical className="h-3.5 w-3.5" />
</button> </button>
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
className="mt-1 h-3.5 w-3.5 shrink-0 accent-violet-500"
aria-label={`${project.name} auswaehlen`}
/>
) : null}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Link <Link
href={`/projects/${project.id}`} href={`/projects/${project.id}`}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "./Markdown";
/**
* Markdown kommt aus zwei Quellen, denen nicht zu trauen ist: eigenen
* Notizen (die zwar der Benutzer schreibt, aber ohne Bereinigung ein
* XSS-Loch waeren) und dem README eines fremden Repositories.
*
* Diese Tests halten die Bereinigung fest. Sie rendern serverseitig, was
* fuer die Frage genuegt – es geht um das erzeugte HTML, nicht um
* Verhalten im Browser.
*/
const html = (text: string, baseUrl?: string) =>
renderToStaticMarkup(<Markdown baseUrl={baseUrl}>{text}</Markdown>);
describe("Bereinigung", () => {
it("laesst kein script durch", () => {
const ausgabe = html("<script>alert(1)</script>\n\nText danach.");
expect(ausgabe).not.toContain("<script");
expect(ausgabe).not.toContain("alert(1)");
expect(ausgabe).toContain("Text danach.");
});
it("entfernt Ereignis-Attribute aus rohem HTML", () => {
const ausgabe = html('<img src="x" onerror="alert(1)">');
expect(ausgabe).not.toContain("onerror");
expect(ausgabe).not.toContain("alert");
});
it("laesst kein javascript: als Link durch", () => {
const ausgabe = html("[klick](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein javascript: ueber ein Bild durch", () => {
// Das Bild wird zu einem Link umgebaut – die Quelle darf dabei nicht
// unbesehen zum href werden.
const ausgabe = html("![x](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein data: mit HTML durch", () => {
const ausgabe = html("[klick](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)");
expect(ausgabe).not.toContain("data:text/html");
});
it("haengt an jeden Link noopener und noreferrer", () => {
const ausgabe = html("[Ziel](https://example.com)");
expect(ausgabe).toContain('target="_blank"');
expect(ausgabe).toContain("noopener");
expect(ausgabe).toContain("noreferrer");
});
it("laedt keine Bilder, sondern zeigt eine Marke", () => {
// Die Content-Security-Policy erlaubt img-src nur von der eigenen
// Instanz; ein fremdes README duerfte sonst Zaehlpixel nachladen.
const ausgabe = html("![Abzeichen](https://example.com/badge.svg)");
expect(ausgabe).not.toContain("<img");
expect(ausgabe).toContain("md-image");
expect(ausgabe).toContain("Abzeichen");
});
});
describe("Relative Verweise", () => {
const BASIS = "https://git.example.com/o/r/blob/main/";
it("loest relative Links gegen die Basis auf", () => {
expect(html("[Doku](docs/x.md)", BASIS)).toContain(`${BASIS}docs/x.md`);
});
it("laesst absolute Links und Anker in Ruhe", () => {
expect(html("[Extern](https://example.com/a)", BASIS)).toContain("https://example.com/a");
expect(html("[Anker](#titel)", BASIS)).toContain('href="#titel"');
});
it("laesst ohne Basis alles unveraendert", () => {
expect(html("[Doku](docs/x.md)")).toContain('href="docs/x.md"');
});
it("erzeugt aus einem Bild im Link keinen Link im Link", () => {
// "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel. Ein a im a ist
// ungueltiges HTML; der Browser zieht es auseinander, was zu einem
// Hydrationsfehler fuehrt – genau daran ist der erste Versuch gescheitert.
const ausgabe = html("[![Abzeichen](img/b.svg)](https://example.com/ziel)", BASIS);
expect(ausgabe).toContain("<span");
expect(ausgabe.match(/<a /g) ?? []).toHaveLength(1);
});
});
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
}, },
}; };
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({ export function Markdown({
children, children,
className, className,
baseUrl,
}: { }: {
children: string | null | undefined; children: string | null | undefined;
className?: string; className?: string;
/**
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
* Basis ins Leere liefen.
*/
baseUrl?: string | null;
}) { }) {
if (!children?.trim()) return null; if (!children?.trim()) return null;
@@ -37,12 +118,22 @@ export function Markdown({
<div className={cn("md", className)}> <div className={cn("md", className)}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]} // Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
// die Bereinigung laufen und dabei ihre Klassen verlieren.
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
components={{ components={{
// Links aus fremdem Inhalt immer in neuem Tab und ohne // Links aus fremdem Inhalt immer in neuem Tab und ohne
// Zugriff auf das oeffnende Fenster. // Zugriff auf das oeffnende Fenster.
a: ({ href, children: content }) => ( // className muss durchgereicht werden, sonst verlieren die
<a href={href} target="_blank" rel="noreferrer noopener nofollow"> // Bildmarken aus dem Umbau oben ihre Gestalt.
a: ({ href, className: klasse, title, children: content }) => (
<a
href={absolut(href, baseUrl)}
className={klasse}
title={title}
target="_blank"
rel="noreferrer noopener nofollow"
>
{content} {content}
</a> </a>
), ),
+246 -55
View File
@@ -3,10 +3,12 @@
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,
LayoutGrid, LayoutGrid,
List,
ListFilter, ListFilter,
NotebookPen, NotebookPen,
Plus, Plus,
@@ -15,16 +17,27 @@ 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 { ImportDialog } from "@/components/ImportDialog"; import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard"; import { KanbanBoard } from "@/components/KanbanBoard";
import { ProjectCard } from "@/components/ProjectCard"; import { ProjectCard } from "@/components/ProjectCard";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { 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;
@@ -52,7 +65,20 @@ function highlight(snippet: string): React.ReactNode[] {
} }
type SortKey = "updated" | "created" | "name" | "progress" | "priority"; type SortKey = "updated" | "created" | "name" | "progress" | "priority";
type ViewMode = "grid" | "grouped" | "kanban"; type ViewMode = "grid" | "list" | "grouped" | "kanban";
const VIEWS: ViewMode[] = ["grid", "list", "grouped", "kanban"];
/** Reihenfolge der Umschalter – zugleich die Beschriftung der Knoepfe. */
const VIEW_META: Record<ViewMode, { label: string; title: string; icon: typeof LayoutGrid }> = {
grid: { label: "Raster", title: "Rasteransicht", icon: LayoutGrid },
list: { label: "Liste", title: "Kompakte Liste", icon: List },
grouped: { label: "Gruppiert", title: "Nach Status gruppiert", icon: Rows3 },
kanban: { label: "Brett", title: "Kanban – Status per Ziehen aendern", icon: Columns3 },
};
/** Wo Ansicht, Sortierung und Archivschalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.board";
const SORT_LABEL: Record<SortKey, string> = { const SORT_LABEL: Record<SortKey, string> = {
updated: "Zuletzt geaendert", updated: "Zuletzt geaendert",
@@ -74,11 +100,53 @@ export function ProjectBoard({
const [sort, setSort] = useState<SortKey>("updated"); const [sort, setSort] = useState<SortKey>("updated");
const [view, setView] = useState<ViewMode>("grid"); const [view, setView] = useState<ViewMode>("grid");
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
/**
* Ansicht und Sortierung ueberdauern das Neuladen.
*
* Wer sein Brett von Hand anordnet, will es beim naechsten Aufruf auch
* sehen und nicht wieder das Raster. Bewusst erst nach dem ersten Rendern
* gelesen: der Server kennt den gespeicherten Wert nicht, und ein
* Unterschied zwischen beiden waere ein Hydrationsfehler.
*
* Der Suchbegriff bleibt aussen vor – eine Suche von gestern beim Oeffnen
* wiederherzustellen waere eher verwirrend als hilfreich.
*/
useEffect(() => {
try {
const gespeichert = window.localStorage.getItem(ANSICHT_SCHLUESSEL);
if (!gespeichert) return;
const wert = JSON.parse(gespeichert) as Partial<{
view: ViewMode;
sort: SortKey;
showArchived: boolean;
}>;
if (wert.view && VIEWS.includes(wert.view)) setView(wert.view);
if (wert.sort && wert.sort in SORT_LABEL) setSort(wert.sort);
if (typeof wert.showArchived === "boolean") setShowArchived(wert.showArchived);
} catch {
// Kaputter oder gesperrter Speicher: dann eben die Voreinstellung.
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
ANSICHT_SCHLUESSEL,
JSON.stringify({ view, sort, showArchived }),
);
} catch {
// Im privaten Modus mancher Browser nicht beschreibbar – kein Grund,
// die Seite scheitern zu lassen.
}
}, [view, sort, showArchived]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [dataMenuOpen, setDataMenuOpen] = useState(false); const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null); const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]); const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [taskHits, setTaskHits] = useState<TaskHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/** /**
* Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite * Notizen liegen im Board nicht vor – sie mitzuladen wuerde die Seite
@@ -90,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;
} }
@@ -100,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.
} }
@@ -114,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(() => {
@@ -143,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)
); );
}); });
@@ -174,6 +248,28 @@ export function ProjectBoard({
})).filter((group) => group.items.length > 0); })).filter((group) => group.items.length > 0);
}, [visible]); }, [visible]);
/**
* Was der Filter ausblendet, bleibt nicht ausgewaehlt. Sonst traefe eine
* Massenaenderung Projekte, die man gar nicht mehr vor Augen hat.
*/
useEffect(() => {
setSelection((current) => {
if (current.size === 0) return current;
const sichtbar = new Set(visible.map((project) => project.id));
const next = new Set([...current].filter((id) => sichtbar.has(id)));
return next.size === current.size ? current : next;
});
}, [visible]);
function toggleSelect(id: string, selected: boolean) {
setSelection((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleStatus(status: ProjectStatus) { function toggleStatus(status: ProjectStatus) {
setStatusFilter((current) => setStatusFilter((current) =>
current.includes(status) current.includes(status)
@@ -186,10 +282,14 @@ export function ProjectBoard({
return ( return (
<section className="space-y-5"> <section className="space-y-5">
{/* Steuerleiste */} {/* Steuerleiste.
<div className="glass flex flex-col gap-4 p-4"> Auf dem Telefon steht die Suche allein in der ersten Zeile und die
<div className="flex flex-wrap items-center gap-3"> Werkzeuge darunter. Frueher lagen beide in einer Zeile, die nicht
<div className="relative min-w-[220px] flex-1"> umbrechen konnte – der Knopf "Neues Projekt" wurde dabei aus dem
Bild geschoben und die ganze Seite seitlich scrollbar. */}
<div className="glass flex flex-col gap-3 p-3 sm:gap-4 sm:p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:min-w-[220px] sm:flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input <input
className="field pl-9" className="field pl-9"
@@ -213,11 +313,22 @@ export function ProjectBoard({
<label className="sr-only" htmlFor="sort"> <label className="sr-only" htmlFor="sort">
Sortierung Sortierung
</label> </label>
{/* min-w-0 ist hier entscheidend: ohne das weigert sich ein
Flex-Kind, unter seine Inhaltsbreite zu schrumpfen, und
drueckt stattdessen die Nachbarn hinaus. */}
<select <select
id="sort" id="sort"
className="field w-auto min-w-[11rem]" className="field w-auto min-w-0 flex-1 truncate sm:flex-none sm:min-w-[11rem]"
value={sort} value={sort}
onChange={(event) => setSort(event.target.value as SortKey)} onChange={(event) => setSort(event.target.value as SortKey)}
// Im Brett zaehlt die Anordnung von Hand. Die Sortierung hier
// stehen zu lassen, ohne dass sie etwas bewirkt, waere irrefuehrend.
disabled={view === "kanban"}
title={
view === "kanban"
? "Im Brett bestimmt die Reihenfolge, in der du die Karten ablegst."
: undefined
}
> >
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => ( {(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -226,50 +337,11 @@ export function ProjectBoard({
))} ))}
</select> </select>
<div className="flex overflow-hidden rounded-xl border border-white/9">
<button
type="button"
onClick={() => setView("grid")}
className={cn(
"p-2 transition-colors",
view === "grid" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Rasteransicht"
title="Rasteransicht"
>
<LayoutGrid className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("grouped")}
className={cn(
"p-2 transition-colors",
view === "grouped" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Nach Status gruppiert"
title="Nach Status gruppiert"
>
<Rows3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setView("kanban")}
className={cn(
"p-2 transition-colors",
view === "kanban" ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-label="Kanban-Spalten"
title="Kanban – Status per Ziehen aendern"
>
<Columns3 className="h-4 w-4" />
</button>
</div>
<button <button
ref={dataButtonRef} ref={dataButtonRef}
type="button" type="button"
onClick={() => setDataMenuOpen((value) => !value)} onClick={() => setDataMenuOpen((value) => !value)}
className="rounded-xl border border-white/9 p-2 text-slate-500 transition-colors hover:text-white" className="shrink-0 rounded-xl border border-white/9 p-2.5 text-slate-500 transition-colors hover:text-white"
aria-label="Daten exportieren oder importieren" aria-label="Daten exportieren oder importieren"
title="Export und Import" title="Export und Import"
aria-haspopup="menu" aria-haspopup="menu"
@@ -278,13 +350,51 @@ export function ProjectBoard({
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
</button> </button>
<button type="button" className="btn btn-primary" onClick={() => setDialogOpen(true)}> <button
type="button"
className="btn btn-primary shrink-0"
onClick={() => setDialogOpen(true)}
// Unterhalb von sm bleibt nur das Pluszeichen stehen. Ohne
// eigene Beschriftung liest ein Screenreader dann gar nichts vor.
aria-label="Neues Projekt"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neues Projekt</span> <span className="hidden sm:inline">Neues Projekt</span>
</button> </button>
</div> </div>
</div> </div>
{/* Ansichtsumschalter: auf schmalen Anzeigen ueber die volle Breite,
damit die vier Knoepfe gross genug zum Treffen bleiben. */}
<div
className="flex overflow-hidden rounded-xl border border-white/9 sm:w-auto sm:self-start"
role="group"
aria-label="Ansicht"
>
{VIEWS.map((mode) => {
const { label, title, icon: Icon } = VIEW_META[mode];
const active = view === mode;
return (
<button
key={mode}
type="button"
onClick={() => setView(mode)}
className={cn(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors sm:flex-none",
active ? "bg-white/10 text-white" : "text-slate-500 hover:text-white",
)}
aria-pressed={active}
title={title}
>
<Icon className="h-4 w-4 shrink-0" />
{/* Nur auf den schmalsten Anzeigen bleiben die Symbole allein –
dort waeren vier Beschriftungen enger als die Fingerkuppe. */}
<span className="hidden xs:inline">{label}</span>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500"> <span className="mr-1 inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-slate-500">
<ListFilter className="h-3.5 w-3.5" /> <ListFilter className="h-3.5 w-3.5" />
@@ -341,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">
@@ -388,11 +552,24 @@ export function ProjectBoard({
{visible.length === 0 ? ( {visible.length === 0 ? (
<EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} /> <EmptyState hasFilters={hasFilters} onCreate={() => setDialogOpen(true)} />
) : view === "kanban" ? ( ) : view === "kanban" ? (
<KanbanBoard projects={visible} showArchived={showArchived} /> <KanbanBoard
projects={visible}
showArchived={showArchived}
selection={selection}
onToggleSelect={toggleSelect}
/>
) : view === "list" ? (
<ProjectList projects={visible} selection={selection} onToggleSelect={toggleSelect} />
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visible.map((project, index) => ( {visible.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
) : ( ) : (
@@ -416,7 +593,13 @@ export function ProjectBoard({
</div> </div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{group.items.map((project, index) => ( {group.items.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} /> <ProjectCard
key={project.id}
project={project}
index={index}
selected={selection.has(project.id)}
onToggleSelect={toggleSelect}
/>
))} ))}
</div> </div>
</div> </div>
@@ -425,6 +608,14 @@ export function ProjectBoard({
</div> </div>
)} )}
{selection.size > 0 ? (
<BulkBar
ids={[...selection]}
onDone={() => setSelection(new Set())}
onClear={() => setSelection(new Set())}
/>
) : null}
<Popover <Popover
anchorRef={dataButtonRef} anchorRef={dataButtonRef}
open={dataMenuOpen} open={dataMenuOpen}
+30 -2
View File
@@ -29,7 +29,17 @@ const PROVIDER_LABEL: Record<string, string> = {
* Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"), * Karte im Board. Die gesamte Flaeche ist ein Link (per "stretched link"),
* interaktive Elemente liegen darueber und bekommen pointer-events zurueck. * interaktive Elemente liegen darueber und bekommen pointer-events zurueck.
*/ */
export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; index?: number }) { export function ProjectCard({
project,
index = 0,
selected = false,
onToggleSelect,
}: {
project: ProjectListItem;
index?: number;
selected?: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent); const accent = accentOf(project.accent);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
// Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es // Auf der Karte ist nur fuer eines Platz: das fuehrende. Wie viele es
@@ -60,7 +70,10 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
return ( return (
<article <article
className="glass card-glow fade-up group relative overflow-hidden p-5" className={cn(
"glass card-glow fade-up group relative overflow-hidden p-5",
selected && "ring-1 ring-violet-400/60",
)}
style={ style={
{ {
"--accent-from": accent.from, "--accent-from": accent.from,
@@ -81,6 +94,21 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem;
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Gedaempft, aber immer da. Nur beim Ueberfahren einzublenden
waere auf einem Touchgeraet gar nicht erreichbar – und wer
es nie sieht, weiss nicht, dass es die Auswahl gibt. */}
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-3.5 w-3.5 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
<h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200"> <h3 className="truncate text-base font-semibold text-white transition-colors group-hover:text-violet-200">
{project.name} {project.name}
</h3> </h3>
+140 -3
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Loader2, RefreshCw, Search } from "lucide-react"; import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status"; import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status";
@@ -17,6 +17,33 @@ interface ProjectDialogProps {
project?: ProjectListItem | null; project?: ProjectListItem | null;
} }
/** Eine Vorlage, so wie sie /api/templates liefert. */
export interface ProjectTemplateEntry {
id: string;
name: string;
description: string | null;
summary: string | null;
projectDescription: string | null;
status: ProjectStatus;
priority: number;
accent: string;
tags: string[];
progressFromTasks: boolean;
notes: unknown[];
tasks: unknown[];
}
/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */
function beschreibeVorlage(vorlage: ProjectTemplateEntry): string {
const teile: string[] = [];
const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0;
const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0;
if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`);
if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`);
if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" "));
return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten";
}
interface FormState { interface FormState {
name: string; name: string;
summary: string; summary: string;
@@ -33,6 +60,8 @@ interface FormState {
repoName: string; repoName: string;
autoSync: boolean; autoSync: boolean;
progressFromTasks: boolean; progressFromTasks: boolean;
/** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */
templateId: string;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -51,6 +80,7 @@ const EMPTY: FormState = {
repoName: "", repoName: "",
autoSync: true, autoSync: true,
progressFromTasks: false, progressFromTasks: false,
templateId: "",
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const isEdit = Boolean(project);
const [templates, setTemplates] = useState<ProjectTemplateEntry[]>([]);
const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null;
/**
* Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum
* Anlegen. Beim Bearbeiten waeren sie nur Ballast.
*/
useEffect(() => {
if (!open || isEdit) return;
let abgebrochen = false;
fetch("/api/templates")
.then((response) => (response.ok ? response.json() : { templates: [] }))
.then((payload) => {
if (!abgebrochen) setTemplates(payload.templates ?? []);
})
.catch(() => undefined);
return () => {
abgebrochen = true;
};
}, [open, isEdit]);
/**
* Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben
* bleiben der Serverseite ueberlassen: sie liessen sich hier nicht
* darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht
* mitgeschleppt bekommen.
*
* Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden
* gefuellt.
*/
function applyTemplate(templateId: string) {
const vorlage = templates.find((entry) => entry.id === templateId);
if (!vorlage) {
update("templateId", "");
return;
}
setForm((previous) => ({
...previous,
templateId,
name: previous.name || vorlage.name,
summary: previous.summary || (vorlage.summary ?? ""),
description: previous.description || (vorlage.projectDescription ?? ""),
status: vorlage.status,
priority: vorlage.priority,
accent: vorlage.accent,
tags: previous.tags || vorlage.tags.join(", "),
progressFromTasks: vorlage.progressFromTasks,
}));
}
async function removeTemplate(vorlage: ProjectTemplateEntry) {
if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return;
await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined);
setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id));
update("templateId", "");
}
const [repoPickerOpen, setRepoPickerOpen] = useState(false); const [repoPickerOpen, setRepoPickerOpen] = useState(false);
const [repos, setRepos] = useState<RepoListEntry[]>([]); const [repos, setRepos] = useState<RepoListEntry[]>([]);
const [reposLoading, setReposLoading] = useState(false); const [reposLoading, setReposLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState(""); const [repoFilter, setRepoFilter] = useState("");
const [repoError, setRepoError] = useState<string | null>(null); const [repoError, setRepoError] = useState<string | null>(null);
const isEdit = Boolean(project);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setError(null); setError(null);
@@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: project.repositories[0]?.repoName ?? "", repoName: project.repositories[0]?.repoName ?? "",
autoSync: project.repositories[0]?.autoSync ?? true, autoSync: project.repositories[0]?.autoSync ?? true,
progressFromTasks: project.progressFromTasks, progressFromTasks: project.progressFromTasks,
templateId: "",
} }
: EMPTY, : EMPTY,
); );
@@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync, autoSync: form.autoSync,
progressFromTasks: form.progressFromTasks, progressFromTasks: form.progressFromTasks,
// Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht.
...(isEdit ? {} : { templateId: form.templateId || null }),
}; };
try { try {
@@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
} }
> >
<form onSubmit={submit} className="space-y-5"> <form onSubmit={submit} className="space-y-5">
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer
Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */}
{!isEdit && templates.length > 0 ? (
<div className="glass-soft p-4">
<label className="field-label" htmlFor="project-template">
Aus Vorlage
</label>
<div className="flex flex-wrap items-center gap-2">
<select
id="project-template"
className="field w-auto min-w-[14rem] flex-1"
value={form.templateId}
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– ohne Vorlage –</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
{gewaehlteVorlage ? (
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs text-rose-200"
onClick={() => removeTemplate(gewaehlteVorlage)}
title="Diese Vorlage loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{gewaehlteVorlage ? (
<p className="mt-1.5 text-xs text-slate-500">
{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""}
{beschreibeVorlage(gewaehlteVorlage)}
</p>
) : (
<p className="mt-1.5 text-xs text-slate-500">
Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter
„Als Vorlage sichern“.
</p>
)}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="field-label" htmlFor="project-name"> <label className="field-label" htmlFor="project-name">
+30 -3
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
ArrowLeft, ArrowLeft,
BookmarkPlus,
CalendarDays, CalendarDays,
CheckSquare, CheckSquare,
Download, Download,
@@ -17,6 +18,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectDialog } from "@/components/ProjectDialog";
import { SaveTemplateDialog } from "@/components/SaveTemplateDialog";
import { StatusSelect } from "@/components/StatusSelect"; import { StatusSelect } from "@/components/StatusSelect";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import { repoTitle } from "@/lib/git/meta"; import { repoTitle } from "@/lib/git/meta";
@@ -36,6 +38,7 @@ export function ProjectHeader({
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2]; const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [templateOpen, setTemplateOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const exportButtonRef = useRef<HTMLButtonElement>(null); const exportButtonRef = useRef<HTMLButtonElement>(null);
const [favorite, setFavorite] = useState(project.favorite); const [favorite, setFavorite] = useState(project.favorite);
@@ -71,7 +74,7 @@ export function ProjectHeader({
return ( return (
<header <header
className="glass relative overflow-hidden p-6" className="glass relative overflow-hidden p-4 sm:p-6"
style={ style={
{ {
"--accent-from": accent.from, "--accent-from": accent.from,
@@ -165,9 +168,13 @@ export function ProjectHeader({
) : null} ) : null}
</div> </div>
<div className="flex flex-col items-end gap-3"> {/* Auf dem Telefon steht dieser Block unter dem Titel und faengt
links an; erst ab sm rueckt er nach rechts neben ihn. Vorher
standen vier Knoepfe in einer Zeile, die nicht umbrechen durfte –
"Loeschen" lag damit ausserhalb des Bildschirms. */}
<div className="flex flex-wrap items-center gap-2 sm:flex-col sm:items-end sm:gap-3">
<StatusSelect projectId={project.id} status={project.status} /> <StatusSelect projectId={project.id} status={project.status} />
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<button <button
ref={exportButtonRef} ref={exportButtonRef}
type="button" type="button"
@@ -176,9 +183,19 @@ export function ProjectHeader({
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={exportOpen} aria-expanded={exportOpen}
title="Exportieren" title="Exportieren"
aria-label="Exportieren"
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
</button> </button>
<button
type="button"
className="btn btn-ghost px-2.5 py-1.5 text-xs"
onClick={() => setTemplateOpen(true)}
title="Als Vorlage sichern"
aria-label="Als Vorlage sichern"
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
<button <button
type="button" type="button"
className="btn btn-ghost px-3 py-1.5 text-xs" className="btn btn-ghost px-3 py-1.5 text-xs"
@@ -284,6 +301,16 @@ export function ProjectHeader({
connections={connections} connections={connections}
project={project} project={project}
/> />
<SaveTemplateDialog
open={templateOpen}
onClose={() => setTemplateOpen(false)}
projectId={project.id}
projectName={project.name}
noteCount={project.notes.length}
// Erledigte kommen nicht mit – hier steht, was wirklich uebernommen wird.
taskCount={project.tasks.filter((task) => task.status !== "DONE").length}
/>
</header> </header>
); );
} }
+204
View File
@@ -0,0 +1,204 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import {
AlertTriangle,
CheckSquare,
GitBranch,
GitCommitHorizontal,
NotebookPen,
Star,
} from "lucide-react";
import { StatusSelect } from "@/components/StatusSelect";
import { PRIORITY_META, accentOf, statusOf } from "@/lib/status";
import { cn, formatRelative } from "@/lib/utils";
import type { ProjectListItem } from "@/types";
/**
* Listenansicht des Boards.
*
* Warum zusaetzlich zum Raster: eine Karte braucht rund 260px Hoehe, auf dem
* Telefon steht sie zwangslaeufig allein in der Zeile. Fuenf Projekte fuellen
* damit zwei Bildschirme, bevor man ueberhaupt etwas vergleichen kann. Eine
* Zeile kommt mit einem Viertel davon aus, und weil alle Zeilen gleich
* aufgebaut sind, liegen Name, Fortschritt und Status untereinander – genau
* das, was das Raster auf schmalen Anzeigen verliert.
*/
export function ProjectList({
projects,
selection,
onToggleSelect,
}: {
projects: ProjectListItem[];
selection: Set<string>;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
return (
<ul className="glass fade-up divide-y divide-white/6 overflow-hidden">
{projects.map((project) => (
<ProjectRow
key={project.id}
project={project}
selected={selection.has(project.id)}
onToggleSelect={onToggleSelect}
/>
))}
</ul>
);
}
function ProjectRow({
project,
selected,
onToggleSelect,
}: {
project: ProjectListItem;
selected: boolean;
onToggleSelect?: (id: string, selected: boolean) => void;
}) {
const accent = accentOf(project.accent);
const meta = statusOf(project.status);
const priority = PRIORITY_META[project.priority] ?? PRIORITY_META[2];
const snapshot = project.repositories[0]?.snapshot ?? null;
const syncError = project.repositories.find((repo) => repo.syncError)?.syncError ?? null;
const [favorite, setFavorite] = useState(project.favorite);
async function toggleFavorite() {
const next = !favorite;
setFavorite(next);
try {
const response = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ favorite: next }),
});
if (!response.ok) throw new Error("failed");
} catch {
setFavorite(!next);
}
}
return (
<li
className={cn("group relative transition-colors", selected && "bg-violet-500/8")}
style={
{
"--accent-from": accent.from,
"--accent-to": accent.to,
} as React.CSSProperties
}
>
{/* Die ganze Zeile ist der Link; was darueber bedienbar sein soll,
bekommt weiter unten die Zeigerereignisse zurueck. */}
<Link
href={`/projects/${project.id}`}
className="absolute inset-0 z-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-400/60"
aria-label={`Projekt ${project.name} oeffnen`}
/>
<div className="pointer-events-none relative z-10 flex items-center gap-2.5 px-3 py-2.5 transition-colors group-hover:bg-white/4 sm:gap-3 sm:px-4 sm:py-3">
{onToggleSelect ? (
<input
type="checkbox"
checked={selected}
onChange={(event) => onToggleSelect(project.id, event.target.checked)}
aria-label={`${project.name} auswaehlen`}
className={cn(
"pointer-events-auto h-4 w-4 shrink-0 accent-violet-500 transition-opacity",
!selected && "opacity-35 group-hover:opacity-100 focus-visible:opacity-100",
)}
/>
) : null}
{/* Statusfarbe als Streifen: auf einen Blick erkennbar, ohne Platz
fuer eine Beschriftung zu verbrauchen. */}
<span
className="h-8 w-1 shrink-0 rounded-full"
style={{ backgroundColor: meta.color }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<h3 className="truncate text-sm font-medium text-white group-hover:text-violet-200">
{project.name}
</h3>
{favorite ? (
<Star className="h-3 w-3 shrink-0 fill-amber-300 text-amber-300" />
) : null}
{syncError ? (
<AlertTriangle className="h-3 w-3 shrink-0 text-rose-300/80" aria-label="Sync-Fehler" />
) : null}
</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-500">
<span className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-white/8 sm:w-24">
<span
className="accent-bar block h-full rounded-full"
style={{ width: `${Math.max(2, project.progress)}%` }}
/>
</span>
<span className="shrink-0 tabular-nums">{project.progress}%</span>
{/* Was nicht mehr in die Zeile passt, faellt der Reihe nach weg,
statt sie zu sprengen. */}
{project._count.tasks > 0 ? (
<span className="hidden shrink-0 items-center gap-1 xs:inline-flex">
<CheckSquare className="h-3 w-3" />
{project.tasks.length}/{project._count.tasks}
</span>
) : null}
{project._count.notes > 0 ? (
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<NotebookPen className="h-3 w-3" />
{project._count.notes}
</span>
) : null}
{snapshot?.defaultBranch ? (
<span className="hidden shrink-0 items-center gap-1 font-mono md:inline-flex">
<GitBranch className="h-3 w-3" />
{snapshot.defaultBranch}
</span>
) : null}
{snapshot?.lastCommitAt ? (
<span
className="hidden shrink-0 items-center gap-1 lg:inline-flex"
suppressHydrationWarning
>
<GitCommitHorizontal className="h-3 w-3" />
{formatRelative(snapshot.lastCommitAt)}
</span>
) : null}
<span
className="ml-auto hidden shrink-0 uppercase tracking-wider md:inline"
style={{ color: priority.color }}
title={`Prioritaet: ${priority.label}`}
>
{priority.label}
</span>
</div>
</div>
<button
type="button"
onClick={toggleFavorite}
aria-label={favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={cn(
"pointer-events-auto hidden shrink-0 rounded-lg p-2 transition-colors sm:block",
favorite
? "text-amber-300 hover:bg-amber-400/10"
: "text-slate-600 hover:bg-white/8 hover:text-amber-300",
)}
>
<Star className={cn("h-4 w-4", favorite && "fill-current")} />
</button>
<div className="pointer-events-auto shrink-0">
<StatusSelect projectId={project.id} status={project.status} variant="compact" />
</div>
</div>
</li>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CheckSquare, Lightbulb, Loader2, Zap } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/utils";
/**
* Schnellerfassung: ein Feld, Enter, fertig.
*
* „Ideen festhalten" ist der erste Satz auf dem Dashboard – der Weg dorthin
* fuehrte aber ueber einen Dialog mit sieben Feldern. Unterwegs auf dem
* Telefon ist das der Unterschied zwischen „schreib ich schnell auf" und
* „mach ich spaeter", und spaeter heisst gar nicht.
*
* Deshalb bleibt hier alles weg, was sich nachziehen laesst: Projekte
* entstehen als Idee, Aufgaben als offen. Beschreibung, Status, Termin und
* der ganze Rest kommen am Rechner dazu, wenn ueberhaupt.
*
* Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine
* Idee notiert, hat meistens gleich die naechste – ihn jedes Mal neu zu
* oeffnen waere genau die Huerde, die hier wegsoll.
*/
interface ProjektHit {
id: string;
name: string;
}
/** Zuletzt gewaehltes Projekt, damit die Aufgabenerfassung nicht jedes Mal fragt. */
const PROJEKT_SCHLUESSEL = "nebula.quickcapture.projekt";
type Modus = "idee" | "aufgabe";
interface Angelegt {
art: Modus;
titel: string;
href: string;
}
export function QuickCapture({
open,
onClose,
initialMode = "idee",
}: {
open: boolean;
onClose: () => void;
/** Aus der Aufgabenuebersicht heraus faengt der Dialog bei "aufgabe" an. */
initialMode?: Modus;
}) {
const router = useRouter();
const eingabeRef = useRef<HTMLInputElement>(null);
const [modus, setModus] = useState<Modus>(initialMode);
const [titel, setTitel] = useState("");
const [projekte, setProjekte] = useState<ProjektHit[]>([]);
const [projektId, setProjektId] = useState("");
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const [angelegt, setAngelegt] = useState<Angelegt[]>([]);
// Beim Oeffnen zuruecksetzen – ein Dialog, der noch die Eingabe von
// vorgestern zeigt, ist schlimmer als einer, der leer ist.
useEffect(() => {
if (!open) return;
setTitel("");
setFehler(null);
setAngelegt([]);
setModus(initialMode);
}, [open, initialMode]);
/**
* Die Projektliste wird erst gebraucht, wenn jemand eine Aufgabe erfasst.
* Sie beim Oeffnen zu holen waere eine Abfrage fuer den haeufigeren Fall,
* in dem sie niemand ansieht.
*/
useEffect(() => {
if (!open || modus !== "aufgabe" || projekte.length > 0) return;
let abgebrochen = false;
(async () => {
try {
const antwort = await fetch("/api/projects");
if (!antwort.ok) return;
const daten = (await antwort.json()) as { projects?: ProjektHit[] };
if (abgebrochen) return;
const liste = (daten.projects ?? []).map(({ id, name }) => ({ id, name }));
setProjekte(liste);
const gemerkt = window.localStorage.getItem(PROJEKT_SCHLUESSEL);
const gueltig = gemerkt && liste.some((eintrag) => eintrag.id === gemerkt);
setProjektId(gueltig ? gemerkt : (liste[0]?.id ?? ""));
} catch {
// Ohne Liste bleibt der Waehler leer; der Hinweis darunter erklaert es.
}
})();
return () => {
abgebrochen = true;
};
}, [open, modus, projekte.length]);
async function absenden(event: React.FormEvent) {
event.preventDefault();
const wert = titel.trim();
if (!wert || busy) return;
setBusy(true);
setFehler(null);
try {
if (modus === "idee") {
const antwort = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
// Nur der Name. Alles andere setzt das Schema: Status IDEA,
// Prioritaet normal, Fortschritt 0.
body: JSON.stringify({ name: wert }),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen.");
setAngelegt((bisher) => [
{ art: "idee", titel: wert, href: `/projects/${nutzlast.project.id}` },
...bisher,
]);
} else {
if (!projektId) throw new Error("Kein Projekt gewaehlt.");
const antwort = await fetch(`/api/projects/${projektId}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: wert }),
});
const nutzlast = await antwort.json().catch(() => ({}));
if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen.");
window.localStorage.setItem(PROJEKT_SCHLUESSEL, projektId);
setAngelegt((bisher) => [
{ art: "aufgabe", titel: wert, href: `/projects/${projektId}` },
...bisher,
]);
}
setTitel("");
eingabeRef.current?.focus();
router.refresh();
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setBusy(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Schnell erfassen"
description="Ein Feld genuegt. Beschreibung, Status und Termin lassen sich spaeter nachziehen."
>
<form onSubmit={absenden} className="space-y-4">
<div className="flex overflow-hidden rounded-xl border border-white/9" role="group">
{(
[
{ wert: "idee", label: "Idee", hinweis: "Neues Projekt", icon: Lightbulb },
{ wert: "aufgabe", label: "Aufgabe", hinweis: "In einem Projekt", icon: CheckSquare },
] as const
).map(({ wert, label, hinweis, icon: Icon }) => (
<button
key={wert}
type="button"
onClick={() => setModus(wert)}
aria-pressed={modus === wert}
className={cn(
"flex flex-1 items-center justify-center gap-2 px-3 py-2.5 text-sm transition-colors",
modus === wert ? "bg-white/10 text-white" : "text-slate-400 hover:text-white",
)}
title={hinweis}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</button>
))}
</div>
<div>
<label className="field-label" htmlFor="quick-title">
{modus === "idee" ? "Wie heisst die Idee?" : "Was ist zu tun?"}
</label>
<input
id="quick-title"
ref={eingabeRef}
className="field"
value={titel}
onChange={(event) => setTitel(event.target.value)}
placeholder={modus === "idee" ? "z. B. Pixelgarten" : "z. B. Favicon austauschen"}
maxLength={modus === "idee" ? 120 : 200}
required
autoFocus
// enterKeyHint stellt auf dem Telefon die Absendetaste um – sonst
// steht dort „Weiter", obwohl es nichts weiter gibt.
enterKeyHint="done"
/>
</div>
{modus === "aufgabe" ? (
<div>
<label className="field-label" htmlFor="quick-project">
Projekt
</label>
{projekte.length > 0 ? (
<select
id="quick-project"
className="field"
value={projektId}
onChange={(event) => setProjektId(event.target.value)}
>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
) : (
<p className="text-sm text-slate-500">
Noch kein Projekt vorhanden – leg zuerst eine Idee an.
</p>
)}
</div>
) : null}
{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}
{angelegt.length > 0 ? (
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-emerald-300/80">
Angelegt
</p>
<ul className="mt-1 space-y-0.5">
{angelegt.slice(0, 5).map((eintrag, index) => (
<li key={`${eintrag.href}-${index}`} className="truncate text-sm">
<Link
href={eintrag.href}
onClick={onClose}
className="text-emerald-100 underline-offset-2 hover:underline"
>
{eintrag.titel}
</Link>
<span className="ml-1.5 text-[11px] text-emerald-300/60">
{eintrag.art === "idee" ? "Projekt" : "Aufgabe"}
</span>
</li>
))}
</ul>
</div>
) : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
{angelegt.length > 0 ? "Fertig" : "Abbrechen"}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={busy || !titel.trim() || (modus === "aufgabe" && !projektId)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
Anlegen
</button>
</div>
</form>
</Modal>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useEffect, useState } from "react";
import { BookmarkPlus, Check, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
/**
* Aus dem offenen Projekt eine Vorlage machen.
*
* Bewusst hier und nicht in einer eigenen Verwaltungsseite: Vorlagen
* entstehen aus einem Projekt, das sich bewaehrt hat. Sie von Grund auf zu
* tippen kann man ueber die API, taeglich braucht das niemand.
*/
export function SaveTemplateDialog({
open,
onClose,
projectId,
projectName,
noteCount,
taskCount,
}: {
open: boolean;
onClose: () => void;
projectId: string;
projectName: string;
noteCount: number;
taskCount: number;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [withNotes, setWithNotes] = useState(true);
const [withTasks, setWithTasks] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!open) return;
setName(projectName);
setDescription("");
setWithNotes(true);
setWithTasks(true);
setError(null);
setDone(false);
}, [open, projectName]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setSaving(true);
setError(null);
try {
const response = await fetch("/api/templates/from-project", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, name, description, withNotes, withTasks }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen.");
setDone(true);
// Kurz stehen lassen, damit die Bestaetigung ankommt.
setTimeout(onClose, 900);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
width="md"
title="Als Vorlage sichern"
description="Neue Projekte starten damit mit denselben Tags, Notizen und Aufgaben."
>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="field-label" htmlFor="template-name">
Name der Vorlage *
</label>
<input
id="template-name"
className="field"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={80}
required
autoFocus
/>
</div>
<div>
<label className="field-label" htmlFor="template-description">
Wofür ist sie gut? (optional)
</label>
<input
id="template-description"
className="field"
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="z. B. Kleine Web-App mit Docker und CI"
maxLength={280}
/>
</div>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withNotes}
onChange={(event) => setWithNotes(event.target.checked)}
disabled={noteCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Notizen übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{noteCount === 0
? "Dieses Projekt hat keine Notizen."
: `${noteCount} ${noteCount === 1 ? "Notiz" : "Notizen"}`}
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={withTasks}
onChange={(event) => setWithTasks(event.target.checked)}
disabled={taskCount === 0}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Aufgaben übernehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
{taskCount === 0
? "Dieses Projekt hat keine offenen Aufgaben."
: `${taskCount} offene ${taskCount === 1 ? "Aufgabe" : "Aufgaben"} — erledigte kommen nicht mit`}
</span>
</span>
</label>
</div>
<p className="rounded-lg border border-white/8 bg-black/25 px-3 py-2 text-xs text-slate-500">
Repositories gehen <strong>nicht</strong> mit. Eine Vorlage, die jedes neue Projekt
an dasselbe Repository hängt, wäre eine Falle.
</p>
{error ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className="btn btn-ghost" onClick={onClose}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={saving || done}>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : done ? (
<Check className="h-4 w-4" />
) : (
<BookmarkPlus className="h-4 w-4" />
)}
{done ? "Gesichert" : "Sichern"}
</button>
</div>
</form>
</Modal>
);
}
+15 -7
View File
@@ -1,4 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatCardProps { interface StatCardProps {
label: string; label: string;
@@ -6,26 +7,33 @@ interface StatCardProps {
hint?: string; hint?: string;
icon: LucideIcon; icon: LucideIcon;
color: string; color: string;
className?: string;
} }
export function StatCard({ label, value, hint, icon: Icon, color }: StatCardProps) { export function StatCard({ label, value, hint, icon: Icon, color, className }: StatCardProps) {
return ( return (
<div className="glass relative overflow-hidden p-4"> <div className={cn("glass relative overflow-hidden p-3 sm:p-4", className)}>
<div <div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl" className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }} style={{ backgroundColor: color }}
aria-hidden="true" aria-hidden="true"
/> />
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500"> {/* Umbrechen statt abschneiden: in der zweispaltigen Anordnung auf
dem Telefon endeten "In Entwicklung" und "Repos verknuepft"
sonst beide als "In Entwickl…" – zwei Kacheln, die sich nicht
mehr unterscheiden liessen. */}
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label} {label}
</p> </p>
<p className="mt-2 text-3xl font-semibold tabular-nums text-white">{value}</p> <p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:mt-2 sm:text-3xl">
{hint ? <p className="mt-1 truncate text-xs text-slate-500">{hint}</p> : null} {value}
</p>
{hint ? <p className="mt-1 text-xs leading-tight text-slate-500">{hint}</p> : null}
</div> </div>
<span <span
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl" className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }} style={{ backgroundColor: `${color}1f`, color }}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-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>
);
}
+52 -7
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
CheckSquare,
GitBranch, GitBranch,
LayoutDashboard, LayoutDashboard,
LogOut, LogOut,
@@ -11,13 +12,16 @@ import {
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
UserRound, UserRound,
Zap,
} from "lucide-react"; } from "lucide-react";
import { QuickCapture } from "@/components/QuickCapture";
import { Popover } from "@/components/ui/Popover"; import { Popover } from "@/components/ui/Popover";
import type { SessionUser } from "@/lib/auth/session"; import type { SessionUser } from "@/lib/auth/session";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const LINKS = [ const LINKS = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard }, { href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/tasks", label: "Aufgaben", icon: CheckSquare },
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch }, { href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
]; ];
@@ -34,16 +38,20 @@ export function TopNav({
return ( return (
<header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl"> <header className="sticky top-0 z-40 border-b border-white/5 bg-[#05060f]/70 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-[1400px] items-center gap-6 px-4 py-3 sm:px-6 lg:px-8"> <div className="mx-auto flex w-full max-w-[1400px] items-center gap-3 px-4 py-3 sm:gap-6 sm:px-6 lg:px-8">
<Link href="/" className="group flex items-center gap-3"> <Link href="/" className="group flex min-w-0 items-center gap-2 sm:gap-3">
<span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105"> <span className="relative grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-violet-500 via-fuchsia-500 to-cyan-400 shadow-[0_8px_28px_-10px_rgba(168,85,247,0.9)] transition-transform group-hover:scale-105">
<Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} /> <Sparkles className="h-4 w-4 text-white" strokeWidth={2.4} />
</span> </span>
<span className="flex flex-col leading-none"> <span className="flex min-w-0 flex-col leading-none">
<span className="text-gradient text-lg font-semibold tracking-tight"> <span className="text-gradient truncate text-lg font-semibold tracking-tight">
{appName} {appName}
</span> </span>
<span className="mt-0.5 text-[10px] uppercase tracking-[0.2em] text-slate-500"> {/* Der weite Zeichenabstand kostet mehr Platz, als die Zeile wert
ist. Seit die Aufgaben als dritter Punkt danebenstehen, brach
sie auf 375px mitten im Wort ab – unterhalb von sm bleibt
deshalb nur der Name. */}
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 sm:block">
{tagline} {tagline}
</span> </span>
</span> </span>
@@ -51,13 +59,20 @@ export function TopNav({
{user ? ( {user ? (
<> <>
<nav className="flex items-center gap-1"> <nav className="flex shrink-0 items-center gap-1">
{LINKS.map(({ href, label, icon: Icon }) => { {LINKS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return ( return (
<Link <Link
key={href} key={href}
href={href} href={href}
// Unterhalb von sm bleibt nur das Symbol stehen. Ohne
// eigene Beschriftung ist der Verweis dann fuer einen
// Screenreader namenlos – axe-core meldet das als
// "link-name", und zwar zu Recht: vorgelesen wird nichts.
aria-label={label}
title={label}
aria-current={active ? "page" : undefined}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors", "flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors",
active active
@@ -72,7 +87,8 @@ export function TopNav({
})} })}
</nav> </nav>
<div className="ml-auto"> <div className="ml-auto flex items-center gap-2">
<QuickCaptureButton />
<UserMenu user={user} /> <UserMenu user={user} />
</div> </div>
</> </>
@@ -82,6 +98,35 @@ export function TopNav({
); );
} }
/**
* Der Einstieg in die Schnellerfassung sitzt in der Kopfleiste, nicht als
* schwebender Knopf unten rechts: dort waere er auf schmalen Anzeigen mit
* der Auswahlleiste kollidiert, die beim Markieren von Projekten einfaehrt.
* In der Kopfleiste ist er auf jeder Seite erreichbar.
*/
function QuickCaptureButton() {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="btn btn-ghost px-2.5 py-1.5 sm:px-3"
// Unterhalb von sm bleibt nur das Symbol – ohne Beschriftung waere
// der Knopf fuer einen Screenreader namenlos.
aria-label="Schnell erfassen"
title="Schnell erfassen – Idee oder Aufgabe"
>
<Zap className="h-4 w-4" />
<span className="hidden lg:inline">Erfassen</span>
</button>
<QuickCapture open={open} onClose={() => setOpen(false)} />
</>
);
}
function UserMenu({ user }: { user: SessionUser }) { function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
+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/);
});
});
+37 -2
View File
@@ -1,7 +1,14 @@
"use client"; "use client";
import { AlignLeft, CalendarClock, GripVertical } 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 { cn, formatDate } from "@/lib/utils"; import { cn, formatDate } from "@/lib/utils";
/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */ /** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */
@@ -69,12 +76,40 @@ export function TaskCard({
{task.title} {task.title}
</p> </p>
{(task.description || task.dueDate || 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}
{task.recurrence ? (
<span
className="inline-flex items-center gap-1 text-slate-500"
title={`Wiederholt sich: ${RECURRENCE_META[task.recurrence].label}`}
>
<Repeat className="h-2.5 w-2.5" />
{RECURRENCE_META[task.recurrence].short}
</span>
) : null}
{task.dueDate ? ( {task.dueDate ? (
<span className={cn("inline-flex items-center gap-1", due?.className)}> <span className={cn("inline-flex items-center gap-1", due?.className)}>
<CalendarClock className="h-2.5 w-2.5" /> <CalendarClock className="h-2.5 w-2.5" />
+130 -4
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 { 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 { 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;
}) { }) {
@@ -36,11 +57,40 @@ export function TaskDialog({
const [status, setStatus] = useState<TaskStatus>(initialStatus); const [status, setStatus] = useState<TaskStatus>(initialStatus);
const [dueDate, setDueDate] = useState(""); const [dueDate, setDueDate] = useState("");
const [labels, setLabels] = useState(""); const [labels, setLabels] = useState("");
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 ?? "");
@@ -48,6 +98,7 @@ export function TaskDialog({
setStatus(task?.status ?? initialStatus); setStatus(task?.status ?? initialStatus);
setDueDate(toDateInput(task?.dueDate ?? null)); setDueDate(toDateInput(task?.dueDate ?? null));
setLabels(task?.labels.join(", ") ?? ""); setLabels(task?.labels.join(", ") ?? "");
setRecurrence(task?.recurrence ?? "");
setError(null); setError(null);
}, [open, task, initialStatus]); }, [open, task, initialStatus]);
@@ -64,6 +115,7 @@ export function TaskDialog({
// Datum nicht auf den Vortag ziehen. // Datum nicht auf den Vortag ziehen.
dueDate: dueDate ? `${dueDate}T12:00:00` : null, dueDate: dueDate ? `${dueDate}T12:00:00` : null,
labels: parseTags(labels), labels: parseTags(labels),
recurrence: recurrence || null,
}; };
try { try {
@@ -194,6 +246,32 @@ export function TaskDialog({
placeholder="bug, ui" placeholder="bug, ui"
/> />
</div> </div>
<div className="sm:col-span-2">
<label className="field-label" htmlFor="task-recurrence">
Wiederholung
</label>
<select
id="task-recurrence"
className="field"
value={recurrence}
onChange={(event) => setRecurrence(event.target.value)}
>
<option value="">– einmalig –</option>
{(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => (
<option key={key} value={key}>
{RECURRENCE_META[key].label}
</option>
))}
</select>
{recurrence ? (
<p className="mt-1 text-xs text-slate-500">
Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet
wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe
bleibt also auf ihrem Wochentag.
</p>
) : null}
</div>
</div> </div>
{error ? ( {error ? (
@@ -202,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
+443
View File
@@ -0,0 +1,443 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CalendarClock,
CheckSquare,
ExternalLink,
Loader2,
Plus,
Repeat,
Search,
Square,
X,
} from "lucide-react";
import type { TaskStatus } from "@prisma/client";
import { QuickCapture } from "@/components/QuickCapture";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import {
DUE_BUCKET_META,
DUE_BUCKET_ORDER,
RECURRENCE_META,
TASK_STATUS_META,
TASK_STATUS_ORDER,
} from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
import type { TaskOverviewItem } from "@/types";
/** Wo Filter und Schalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.tasks";
/**
* Aufgaben ueber alle Projekte hinweg.
*
* Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
* lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
* Aus demselben Grund laesst sich hier bewusst **nichts ziehen** – die
* Position einer Aufgabe gilt innerhalb ihrer Spalte in ihrem Projekt und
* waere hier ohne Bedeutung. Eine Reihenfolge anzubieten, die nirgends
* ankommt, waere ein Versprechen, das die Seite nicht halten kann.
*/
export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState("");
const [suche, setSuche] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false);
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 map = new Map<string, string>();
for (const task of tasks) map.set(task.project.id, task.project.name);
return [...map.entries()]
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}, [tasks]);
const zaehler = useMemo(() => {
const map = new Map<TaskStatus, number>();
for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1);
return map;
}, [tasks]);
const sichtbar = useMemo(() => {
const nadel = suche.toLowerCase().trim();
return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
// 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;
}
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
if (projektFilter && task.project.id !== projektFilter) return false;
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, suche]);
const faecher = useMemo(() => {
return DUE_BUCKET_ORDER.map((bucket) => ({
bucket,
items: sichtbar.filter((task) => task.bucket === bucket),
})).filter((fach) => fach.items.length > 0);
}, [sichtbar]);
const offen = sichtbar.filter((task) => task.status !== "DONE").length;
const ueberfaellig = sichtbar.filter(
(task) => task.bucket === "overdue" && task.status !== "DONE",
).length;
function toggleStatus(status: TaskStatus) {
setStatusFilter((current) =>
current.includes(status)
? current.filter((entry) => entry !== status)
: [...current, status],
);
}
/**
* Abhaken direkt aus der Uebersicht. Der Endpunkt erledigt dabei den Rest:
* doneAt, abgeleiteter Fortschritt und – bei wiederkehrenden Aufgaben –
* die naechste Fassung.
*/
async function abhaken(task: TaskOverviewItem) {
const ziel: TaskStatus = task.status === "DONE" ? "TODO" : "DONE";
setLaufend((current) => new Set(current).add(task.id));
try {
const antwort = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: ziel }),
});
if (!antwort.ok) throw new Error("fehlgeschlagen");
router.refresh();
} catch {
// Beim naechsten Laden steht wieder der echte Stand da.
} finally {
setLaufend((current) => {
const next = new Set(current);
next.delete(task.id);
return next;
});
}
}
return (
<div className="space-y-5">
<header className="fade-up flex flex-wrap items-end justify-between gap-3 pt-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Aufgaben
</h1>
<p className="mt-1.5 text-sm text-slate-400">
{offen} offen
{ueberfaellig > 0 ? (
<>
{" · "}
<span className="text-rose-300">{ueberfaellig} überfällig</span>
</>
) : null}
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setErfassenOffen(true)}
aria-label="Neue Aufgabe"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neue Aufgabe</span>
</button>
</header>
{/* Steuerleiste */}
<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">
{TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status];
const aktiv = statusFilter.includes(status);
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
aria-pressed={aktiv}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
aktiv
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: aktiv ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{zaehler.get(status) ?? 0}</span>
</button>
);
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="sr-only" htmlFor="task-project-filter">
Projekt
</label>
<select
id="task-project-filter"
className="field w-auto min-w-0 sm:min-w-[14rem] sm:flex-none"
value={projektFilter}
onChange={(event) => setProjektFilter(event.target.value)}
>
<option value="">Alle Projekte</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500 sm:ml-auto">
<input
type="checkbox"
checked={zeigeErledigte}
onChange={(event) => setZeigeErledigte(event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Erledigte einblenden
</label>
</div>
</div>
{faecher.length === 0 ? (
<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">
<CheckSquare className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{tasks.length === 0
? "Noch keine Aufgaben"
: suche.trim()
? "Keine Treffer"
: "Nichts offen"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{tasks.length === 0
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
: suche.trim()
? `Keine Aufgabe passt zu „${suche.trim()}“ – gesucht wird in Titel, Beschreibung, Labels und Projektname.`
: "Alles abgehakt, was zu den Filtern passt."}
</p>
</div>
</div>
) : (
<div className="space-y-6">
{faecher.map((fach) => {
const meta = DUE_BUCKET_META[fach.bucket];
return (
<section key={fach.bucket}>
<div className="mb-2.5 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{fach.items.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">
{fach.items.map((task) => (
<TaskRow
key={task.id}
task={task}
busy={laufend.has(task.id)}
onToggle={() => abhaken(task)}
onEdit={() => setBearbeiten(task)}
/>
))}
</ul>
</section>
);
})}
</div>
)}
<QuickCapture
open={erfassenOffen}
onClose={() => setErfassenOffen(false)}
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>
);
}
function TaskRow({
task,
busy,
onToggle,
onEdit,
}: {
task: TaskOverviewItem;
busy: boolean;
onToggle: () => void;
onEdit: () => void;
}) {
const meta = TASK_STATUS_META[task.status];
const erledigt = task.status === "DONE";
return (
<li className="flex items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/4 sm:gap-3 sm:px-4">
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={erledigt ? `„${task.title}“ wieder öffnen` : `„${task.title}“ abhaken`}
className="shrink-0 rounded-lg p-1.5 text-slate-500 transition-colors hover:text-emerald-300 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : erledigt ? (
<CheckSquare className="h-4 w-4 text-emerald-300" />
) : (
<Square className="h-4 w-4" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{/* 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(
"truncate text-left text-sm transition-colors hover:text-violet-200",
erledigt ? "text-slate-500 line-through" : "text-slate-100",
)}
title="Aufgabe bearbeiten"
>
{task.title}
</button>
{task.recurrence ? (
<Repeat
className="h-3 w-3 shrink-0 text-slate-500"
aria-label={RECURRENCE_META[task.recurrence].label}
/>
) : null}
{task.sourceUrl ? (
<a
href={task.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-slate-500 transition-colors hover:text-violet-300"
aria-label="Zugehöriges Issue öffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<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/${task.project.id}`}
className="max-w-[10rem] truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
{task.project.name}
</Link>
<span
className={cn("inline-flex items-center gap-1 rounded-full px-1.5 ring-1 ring-inset", meta.className)}
>
{meta.label}
</span>
{task.dueDate ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1",
task.bucket === "overdue" && !erledigt && "text-rose-300",
)}
>
<CalendarClock className="h-3 w-3" />
{formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 2).map((label) => (
<span key={label} className="hidden rounded bg-white/5 px-1.5 text-slate-400 xs:inline">
#{label}
</span>
))}
</div>
</div>
</li>
);
}
+45 -2
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-4 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"
@@ -65,7 +108,7 @@ export function Modal({
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
className={cn( className={cn(
"popover fade-up relative z-10 my-auto w-full rounded-2xl p-6", "popover fade-up relative z-10 my-auto w-full rounded-2xl p-4 sm:p-6",
width === "md" && "max-w-lg", width === "md" && "max-w-lg",
width === "lg" && "max-w-2xl", width === "lg" && "max-w-2xl",
width === "xl" && "max-w-4xl", width === "xl" && "max-w-4xl",
+15 -6
View File
@@ -44,7 +44,11 @@ export function Popover({
}) { }) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const [position, setPosition] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
@@ -56,14 +60,19 @@ export function Popover({
const margin = 8; const margin = 8;
const gap = 6; const gap = 6;
// Passt die Wunschbreite nicht ins Fenster, gilt das Fenster. Sonst
// liefert die Klemmung unten eine negative linke Kante und das Menue
// stuende zur Haelfte ausserhalb des Bildschirms.
const effektiv = Math.min(width, window.innerWidth - 2 * margin);
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln. // Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit // Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
// links stuende das Menue sonst quer ueber der Seite statt an seinem // links stuende das Menue sonst quer ueber der Seite statt an seinem
// Ausloeser. // Ausloeser.
let left = align === "end" ? rect.right - width : rect.left; let left = align === "end" ? rect.right - effektiv : rect.left;
if (left < margin) left = rect.left; if (left < margin) left = rect.left;
if (left + width > window.innerWidth - margin) left = rect.right - width; if (left + effektiv > window.innerWidth - margin) left = rect.right - effektiv;
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin); left = Math.min(Math.max(margin, left), window.innerWidth - effektiv - margin);
const panelHeight = panelRef.current?.offsetHeight ?? 0; const panelHeight = panelRef.current?.offsetHeight ?? 0;
let top = rect.bottom + gap; let top = rect.bottom + gap;
@@ -73,7 +82,7 @@ export function Popover({
const fitsAbove = rect.top - gap - panelHeight >= margin; const fitsAbove = rect.top - gap - panelHeight >= margin;
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight; if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
setPosition({ top, left }); setPosition({ top, left, width: effektiv });
}, [anchorRef, align, width]); }, [anchorRef, align, width]);
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim // Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
@@ -127,7 +136,7 @@ export function Popover({
position: "fixed", position: "fixed",
top: position?.top ?? -9999, top: position?.top ?? -9999,
left: position?.left ?? -9999, left: position?.left ?? -9999,
width, width: position?.width ?? width,
// Bis die Position steht, unsichtbar halten – sonst blitzt das // Bis die Position steht, unsichtbar halten – sonst blitzt das
// Menue kurz an der falschen Stelle auf. // Menue kurz an der falschen Stelle auf.
visibility: position ? "visible" : "hidden", visibility: position ? "visible" : "hidden",
+5 -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,
@@ -75,6 +75,8 @@ export interface PendingLoginState {
isActive: boolean; isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */ /** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null; totpSecret: string | null;
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
totpLastCounter: number | null;
}; };
} }
@@ -104,6 +106,7 @@ export async function readPendingLogin(): Promise<PendingLoginState | null> {
role: pending.user.role, role: pending.user.role,
isActive: pending.user.isActive, isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher), totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
totpLastCounter: pending.user.totpLastCounter,
}, },
}; };
} }
+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);

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