6 Commits
Author SHA1 Message Date
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
36 changed files with 2798 additions and 25 deletions
+130 -1
View File
@@ -109,8 +109,25 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
## 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
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.
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
@@ -273,6 +290,46 @@ 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).
---
## 3. Notizen
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
@@ -446,6 +503,78 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
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** über Webhooks, siehe unten.
**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
+33 -6
View File
@@ -11,12 +11,19 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
## 1 · Als Nächstes empfohlen
### 1.1 Webhooks statt Polling `M`
### 1.0 Rest aus v1.6.0: persönliche API-Token `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per
Shortcut.
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
Migration wert.
---
@@ -36,6 +43,24 @@ Sitzungen.
---
### 1.4 Aufgaben ↔ Issues: was noch fehlt `S`
Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über
Webhooks. Offen geblieben ist Kleinkram:
- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
Projekt aus Frontend und Backend fehlt die Wahl.
- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch
eingerichtet — der Webhook ist getrennt zu setzen.
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 · Größere Brocken
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
@@ -58,7 +83,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab |
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
@@ -111,6 +136,8 @@ Damit klar ist, was **nicht** kommt und warum:
| **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 | ✓ |
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
+103
View File
@@ -279,6 +279,109 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
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);
+92
View File
@@ -131,6 +131,98 @@ test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }
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("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "1.5.0",
"version": "1.7.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3),
ADD COLUMN "webhookSecret" TEXT;
+10
View File
@@ -407,6 +407,16 @@ model Repository {
notifiedCiState 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?
createdAt DateTime @default(now())
@@ -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 });
}
+141
View File
@@ -0,0 +1,141 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { APP_URL } from "@/lib/config";
import { prisma } from "@/lib/db";
import { GitApiError, clientForConnection } from "@/lib/git";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
* Legt zu einer Aufgabe ein Issue im verknuepften Repository an.
*
* Der einzige Weg, auf dem Nebula von sich aus etwas in ein fremdes System
* schreibt – deshalb ausdruecklich angestossen und nie nebenbei. Umgekehrt
* gibt es die Uebernahme von Issues als Aufgaben schon laenger
* (tasks/from-issues); die Felder sourceIssue, sourceUrl und
* sourceRepositoryId sind dieselben, und die Eindeutigkeit je Repository
* verhindert, dass beide Wege dasselbe doppelt anlegen.
*/
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Besitz haengt am Projekt – die Aufgabe selbst kennt keinen Besitzer.
const task = await prisma.task.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: {
id: true,
title: true,
description: true,
projectId: true,
sourceIssue: true,
sourceUrl: true,
project: { select: { name: true } },
},
});
if (!task) return jsonError("Aufgabe nicht gefunden", 404);
// Schon verknuepft: nicht noch einmal anlegen. Ein zweites Issue zur
// selben Aufgabe waere genau die Dublette, die from-issues vermeidet.
if (task.sourceIssue !== null) {
return NextResponse.json(
{
error: "Diese Aufgabe haengt bereits an einem Issue.",
issue: { number: task.sourceIssue, url: task.sourceUrl },
},
{ status: 409 },
);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = schema.safeParse(body.data ?? {});
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
const repository = await prisma.repository.findFirst({
where: {
projectId: task.projectId,
...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}),
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { connection: true },
});
if (!repository) {
return jsonError("Das Projekt hat kein verknuepftes Repository.", 422);
}
if (!repository.connection) {
return jsonError(
"Das Repository haengt an keiner Git-Verbindung – ohne Token laesst sich nichts anlegen.",
422,
);
}
if (!repository.repoOwner || !repository.repoName) {
return jsonError("Am Repository fehlen Owner oder Name.", 422);
}
const client = clientForConnection(repository.connection);
// Rueckverweis in den Rumpf: wer das Issue im Git-System liest, soll
// wissen, wo es herkommt und wo der Rest der Notizen liegt.
const rumpf = [
task.description?.trim() || null,
`_Angelegt aus [${task.project.name}](${APP_URL}/projects/${task.projectId}) in Nebula._`,
]
.filter(Boolean)
.join("\n\n");
try {
const issue = await client.createIssue(
{ owner: repository.repoOwner, name: repository.repoName },
{ title: task.title, body: rumpf },
);
const aktualisiert = await prisma.task.update({
where: { id: task.id },
data: {
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
});
await recordActivity({
projectId: task.projectId,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Issue #${issue.number} angelegt zu: ${shorten(task.title, 50)}`,
meta: { taskId: task.id, issue: issue.number, url: issue.url },
});
return NextResponse.json({ task: aktualisiert, issue }, { status: 201 });
} catch (error) {
// Der Aufruf ging nach draussen; die Meldung des Git-Systems ist
// hilfreicher als ein pauschales "hat nicht geklappt".
if (error instanceof GitApiError) {
return jsonError(`Issue konnte nicht angelegt werden: ${error.message}`, 502);
}
return jsonError("Issue konnte nicht angelegt werden.", 502);
}
}
+18
View File
@@ -10,6 +10,7 @@ import {
refreshDerivedProgress,
spawnRecurrence,
} from "@/lib/tasks";
import { issueShouldBeOpen, mirrorStatusToIssue } from "@/lib/tasks/issues";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -74,6 +75,23 @@ export async function PATCH(request: Request, { params }: Params) {
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
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
@@ -0,0 +1,136 @@
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 { 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 !== task.status) {
await prisma.task.update({
where: { id: task.id },
data: {
status: ziel,
doneAt: ziel === "DONE" ? new Date() : 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 });
}
+5 -1
View File
@@ -11,7 +11,7 @@ import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard";
import { getSessionUser } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects";
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
import type { ConnectionSafe, ProjectDetail } from "@/types";
export const dynamic = "force-dynamic";
@@ -55,6 +55,9 @@ export default async function ProjectPage({ params }: PageProps) {
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
? {
@@ -116,6 +119,7 @@ export default async function ProjectPage({ params }: PageProps) {
<GitPanel
project={detail}
connections={connections as unknown as ConnectionSafe[]}
webhookRepoIds={webhookIds}
/>
<ActivityFeed projectId={detail.id} initial={detail.activities} />
</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} />;
}
+8
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
CheckSquare,
CornerDownLeft,
GitBranch,
LayoutDashboard,
@@ -55,6 +56,13 @@ interface Action {
const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
{
id: "tasks",
label: "Aufgaben",
hint: "Über alle Projekte, nach Fälligkeit",
icon: CheckSquare,
href: "/tasks",
},
{
id: "connections",
label: "Git-Verbindungen",
+36
View File
@@ -17,6 +17,7 @@ import {
Lock,
Package,
Plus,
Radio,
RefreshCw,
ShieldCheck,
Star,
@@ -26,6 +27,7 @@ import {
import { CiBadge } from "@/components/CiBadge";
import { Heatmap } from "@/components/Heatmap";
import { RepositoryDialog } from "@/components/RepositoryDialog";
import { WebhookDialog } from "@/components/WebhookDialog";
import { Sparkline } from "@/components/Sparkline";
import { repoTitle } from "@/lib/git/meta";
import { asArray, asCalendar, asRecord } from "@/types";
@@ -55,9 +57,13 @@ type TabKey = (typeof TABS)[number]["key"];
export function GitPanel({
project,
connections,
webhookRepoIds = [],
}: {
project: ProjectDetail;
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 [tab, setTab] = useState<TabKey>("pulls");
@@ -65,6 +71,7 @@ export function GitPanel({
const [importing, setImporting] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [webhookOpen, setWebhookOpen] = useState(false);
const repositories = project.repositories;
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
@@ -72,6 +79,7 @@ export function GitPanel({
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
// zurueckfallen, statt eine leere Ansicht zu zeigen.
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. */
async function importIssues() {
@@ -280,6 +288,24 @@ export function GitPanel({
Repository
</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
type="button"
onClick={() => removeRepository(repository)}
@@ -312,6 +338,16 @@ export function GitPanel({
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 ? (
<p
className={cn(
+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>
);
}
+40 -4
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
CheckSquare,
GitBranch,
LayoutDashboard,
LogOut,
@@ -11,13 +12,16 @@ import {
ShieldCheck,
Sparkles,
UserRound,
Zap,
} from "lucide-react";
import { QuickCapture } from "@/components/QuickCapture";
import { Popover } from "@/components/ui/Popover";
import type { SessionUser } from "@/lib/auth/session";
import { cn } from "@/lib/utils";
const LINKS = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/tasks", label: "Aufgaben", icon: CheckSquare },
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
];
@@ -43,9 +47,11 @@ export function TopNav({
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
{appName}
</span>
{/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die
Zeile wert ist – dort bleibt nur der Name stehen. */}
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 xs:block">
{/* 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}
</span>
</span>
@@ -81,7 +87,8 @@ export function TopNav({
})}
</nav>
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<QuickCaptureButton />
<UserMenu user={user} />
</div>
</>
@@ -91,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 }) {
const router = useRouter();
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>
);
}
+100 -3
View File
@@ -2,12 +2,33 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react";
import type { Recurrence, Task, TaskStatus } from "@prisma/client";
import { ExternalLink, GitPullRequestArrow, Loader2, Trash2 } from "lucide-react";
import type { Recurrence, TaskStatus } from "@prisma/client";
import { Modal } from "@/components/ui/Modal";
import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks";
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. */
function toDateInput(value: Date | string | null): string {
if (!value) return "";
@@ -26,7 +47,7 @@ export function TaskDialog({
}: {
open: boolean;
projectId: string;
task: Task | null;
task: EditableTask | null;
initialStatus: TaskStatus;
onClose: () => void;
}) {
@@ -38,10 +59,38 @@ export function TaskDialog({
const [labels, setLabels] = useState("");
const [recurrence, setRecurrence] = useState<string>("");
const [busy, setBusy] = useState(false);
const [issueBusy, setIssueBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
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(() => {
if (!open) return;
setTitle(task?.title ?? "");
@@ -231,6 +280,54 @@ export function TaskDialog({
</p>
) : 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">
{isEdit ? (
<button
+395
View File
@@ -0,0 +1,395 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CalendarClock,
CheckSquare,
ExternalLink,
Loader2,
Plus,
Repeat,
Square,
} 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 [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(() => {
return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
if (!zeigeErledigte && 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;
return true;
});
}, [tasks, statusFilter, projektFilter, zeigeErledigte]);
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="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" : "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“."
: "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>
);
}
+44 -1
View File
@@ -33,9 +33,45 @@ export function Modal({
width = "lg",
}: ModalProps) {
const [mounted, setMounted] = useState(false);
const [sichtfenster, setSichtfenster] = useState<{ height: number; top: number } | null>(null);
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(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
@@ -53,7 +89,14 @@ export function Modal({
if (!mounted || !open) return null;
return createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8">
<div
className="fixed inset-x-0 z-[120] flex items-start justify-center overflow-y-auto p-3 sm:p-8"
style={
sichtfenster
? { top: sichtfenster.top, height: sichtfenster.height }
: { top: 0, bottom: 0 }
}
>
<button
type="button"
aria-label="Dialog schliessen"
+90
View File
@@ -41,6 +41,96 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
};
export const CHANGELOG: Release[] = [
{
version: "1.7.0",
date: "2026-08-15",
headline: "Aufgaben und Git-Issues finden zusammen.",
changes: [
{
kind: "neu",
text:
"Eine Aufgabe lässt sich als Issue im verknüpften Repository anlegen — per Knopf, " +
"nie nebenbei. Danach wandert der Zustand mit: erledigt schließt das Issue, ein " +
"Zurückziehen öffnet es wieder.",
},
{
kind: "neu",
text:
"Webhooks. Ein Push aktualisiert den Stand sofort, statt bis zum nächsten " +
"Abgleich zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Einzurichten " +
"am Repository — URL und Geheimnis stehen dort bereit.",
},
{
kind: "behoben",
text:
"Auf dem Telefon verdeckte die Bildschirmtastatur den halben Dialog, weil er mit " +
"der vollen Fensterhöhe rechnete. Dialoge richten sich jetzt nach dem sichtbaren " +
"Bereich und rücken über die Tastatur.",
},
{
kind: "besser",
text:
"Aufgaben lassen sich in der Übersicht direkt bearbeiten. Bisher führte von dort " +
"nur der Weg ins Projekt.",
},
],
},
{
version: "1.6.0",
date: "2026-08-14",
headline: "Unterwegs erfassen — und eine Oberfläche, die aufs Telefon passt.",
changes: [
{
kind: "behoben",
text:
"Auf dem Telefon war das Dashboard breiter als der Bildschirm: die Steuerleiste " +
"konnte nicht umbrechen und schob den Knopf „Neues Projekt“ aus dem Bild. " +
"Dasselbe im Projektkopf, wo „Löschen“ außerhalb lag. Beide Zeilen brechen " +
"jetzt um.",
},
{
kind: "neu",
text:
"Schnellerfassung: ein Blitzsymbol in der Kopfleiste, auf jeder Seite " +
"erreichbar. Ein Feld, Enter, fertig — als Idee entsteht ein Projekt, als " +
"Aufgabe eine Aufgabe im gewählten Projekt. Der Dialog bleibt danach offen, " +
"weil auf eine Idee meist gleich die nächste folgt.",
},
{
kind: "neu",
text:
"Eine Aufgabenseite über alle Projekte hinweg, geordnet nach Fälligkeit: " +
"überfällig, heute, diese Woche, später, ohne Termin. Abhaken geht direkt " +
"aus der Liste.",
},
{
kind: "neu",
text:
"Vierte Ansicht im Dashboard: eine kompakte Liste. Auf dem Telefon passen " +
"damit fünf Projekte auf einen Bildschirm statt auf vier.",
},
{
kind: "besser",
text:
"Eingabefelder sind auf Berührungsgeräten 16 Pixel groß — darunter zoomt " +
"iOS Safari beim Hineintippen die Seite heran und kehrt nicht von selbst " +
"zurück. Knöpfe und der Fortschrittsregler sind mit dem Finger zu treffen.",
},
{
kind: "behoben",
text:
"Die Verweise in der Kopfleiste und „Neues Projekt“ sind auf schmalen " +
"Anzeigen reine Symbole und hießen für einen Screenreader gar nichts. " +
"Jetzt tragen sie eine Beschriftung.",
},
{
kind: "behoben",
text:
"Das Tag-Formular der Auswahlleiste war breiter als sein Menü und wurde " +
"beschnitten — auf jeder Bildschirmbreite.",
},
],
},
{
version: "1.5.0",
date: "2026-07-27",
+28 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
@@ -12,6 +12,7 @@ import type {
CiState,
CommitInfo,
ConnectionCheck,
CreatedIssue,
GitClient,
ProviderConfig,
ReleaseInfo,
@@ -44,6 +45,32 @@ export class GiteaClient implements GitClient {
return headers;
}
private repoBase(ref: RepoRef): string {
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
}
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
const angelegt = await sendJson<{ number: number; html_url?: string }>(
`${this.repoBase(ref)}/issues`,
"POST",
{ title: issue.title, body: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.number, url: angelegt.html_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.repoBase(ref)}/issues/${number}`,
"PATCH",
{ state: open ? "open" : "closed" },
{ headers: this.headers() },
);
}
async check(): Promise<ConnectionCheck> {
try {
const version = await getJsonSafe<{ version?: string }>(
+28 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import type {
BranchInfo,
CiInfo,
@@ -6,6 +6,7 @@ import type {
CommitCalendar,
CommitInfo,
ConnectionCheck,
CreatedIssue,
GitClient,
ProviderConfig,
ReleaseInfo,
@@ -43,6 +44,32 @@ export class GitHubClient implements GitClient {
return headers;
}
private repoBase(ref: RepoRef): string {
return `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
}
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
const angelegt = await sendJson<{ number: number; html_url?: string }>(
`${this.repoBase(ref)}/issues`,
"POST",
{ title: issue.title, body: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.number, url: angelegt.html_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.repoBase(ref)}/issues/${number}`,
"PATCH",
{ state: open ? "open" : "closed" },
{ headers: this.headers() },
);
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
+26 -1
View File
@@ -1,4 +1,4 @@
import { getJson, getJsonSafe, normalizeBaseUrl, shortSha, toIso } from "./http";
import { getJson, getJsonSafe, normalizeBaseUrl, sendJson, shortSha, toIso } from "./http";
import {
CALENDAR_PAGE_SIZE,
calendarByPaging,
@@ -12,6 +12,7 @@ import type {
CiState,
CommitInfo,
ConnectionCheck,
CreatedIssue,
GitClient,
ProviderConfig,
ReleaseInfo,
@@ -45,6 +46,30 @@ export class GitLabClient implements GitClient {
return headers;
}
async createIssue(
ref: RepoRef,
issue: { title: string; body?: string | null },
): Promise<CreatedIssue> {
// GitLab nennt es "iid" – die projektinterne Nummer, die auch in der
// Oberflaeche steht. "id" waere die instanzweite und damit die falsche.
const angelegt = await sendJson<{ iid: number; web_url?: string }>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues`,
"POST",
{ title: issue.title, description: issue.body ?? "" },
{ headers: this.headers() },
);
return { number: angelegt.iid, url: angelegt.web_url ?? null };
}
async setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void> {
await sendJson(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
"PUT",
{ state_event: open ? "reopen" : "close" },
{ headers: this.headers() },
);
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
+77
View File
@@ -98,6 +98,83 @@ export async function getJson<T>(
}
}
/**
* Schreibender Aufruf – POST, PATCH oder PUT.
*
* Bewusst dieselbe Schleuse wie getJson: die Basis-URL stammt aus einem
* Formular, und ohne die Pruefung waere ein schreibender Aufruf der weitaus
* unangenehmere Weg nach innen. Redirects werden hier **nicht** verfolgt.
* Ein 307 auf eine interne Adresse wuerde den Rumpf mitnehmen, und der
* enthaelt bei uns immer den Token im Header – lieber ein Fehler als eine
* Anfrage, die woanders landet.
*/
export async function sendJson<T>(
url: string,
method: "POST" | "PATCH" | "PUT",
body: unknown,
options: FetchOptions = {},
): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
options.timeoutMs ?? DEFAULT_TIMEOUT,
);
try {
await assertAllowedUrl(url);
const response = await fetch(url, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": "Nebula-Dashboard",
...options.headers,
},
body: JSON.stringify(body),
signal: controller.signal,
cache: "no-store",
redirect: "manual",
});
// Bei redirect: "manual" kommt der Umleitungsstatus durch, statt dass
// fetch ihm folgt. Das ist hier gewollt und ein Fehlerfall.
if (response.status >= 300 && response.status < 400) {
throw new GitApiError(
"Das Git-System hat umgeleitet. Schreibende Aufrufe folgen dem nicht – " +
"bitte die Basis-URL der Verbindung auf die endgueltige Adresse setzen.",
response.status,
url,
);
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new GitApiError(friendlyMessage(response.status, url, text), response.status, url);
}
const text = await response.text();
if (!text) return null as T;
return JSON.parse(text) as T;
} catch (error) {
if (error instanceof GitApiError) throw error;
if (error instanceof BlockedHostError) {
throw new GitApiError(error.message, 403, url);
}
if (error instanceof Error && error.name === "AbortError") {
throw new GitApiError(
`Zeitueberschreitung nach ${options.timeoutMs ?? DEFAULT_TIMEOUT} ms`,
408,
url,
);
}
const reason = error instanceof Error ? error.message : String(error);
throw new GitApiError(`Verbindung fehlgeschlagen: ${reason}`, 0, url);
} finally {
clearTimeout(timeout);
}
}
/** Wie getJson, gibt aber bei jedem Fehler den Fallback zurueck. */
export async function getJsonSafe<T>(
url: string,
+19
View File
@@ -167,10 +167,29 @@ export interface ProviderConfig {
token: string | null;
}
/** Ein neu angelegtes Issue, so weit wir es zurueckschreiben. */
export interface CreatedIssue {
number: number;
url: string | null;
}
export interface GitClient {
readonly provider: ProviderKind;
/** Prueft Erreichbarkeit und Token-Gueltigkeit. */
check(): Promise<ConnectionCheck>;
/**
* Legt ein Issue an und liefert Nummer und Adresse zurueck.
*
* Der einzige schreibende Aufruf in dieser Schnittstelle. Er wird nie
* nebenbei ausgeloest, sondern immer von Hand angestossen – was nach
* aussen geht, soll jemand gewollt haben.
*/
createIssue(ref: RepoRef, issue: { title: string; body?: string | null }): Promise<CreatedIssue>;
/**
* Schliesst ein Issue. Beim Zurueckziehen einer Aufgabe wird es wieder
* geoeffnet – daher der Schalter statt zweier Methoden.
*/
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
+50 -1
View File
@@ -65,17 +65,66 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
updatedAt: true,
});
/**
* Felder eines Repositories, die nach aussen duerfen.
*
* Ausdruecklich als Liste und nicht als "include": ein include liefert jedes
* Skalarfeld mit, auch neu hinzugekommene. Das Webhook-Geheimnis waere so
* still in jede Projektantwort und in jede Seite gewandert – verschluesselt
* zwar, aber nach aussen gehoert es trotzdem nicht. Dieselbe Ueberlegung wie
* bei connectionSafeSelect.
*
* Ob ein Webhook eingerichtet ist, sagt webhookLastAt nicht zuverlaessig
* (frisch eingerichtet ist es null). Diese Angabe holt die Projektseite
* deshalb getrennt – siehe webhookRepositoryIds.
*/
export const repositorySafeSelect = Prisma.validator<Prisma.RepositorySelect>()({
id: true,
projectId: true,
label: true,
repoUrl: true,
repoOwner: true,
repoName: true,
defaultBranch: true,
connectionId: true,
position: true,
lastSyncedAt: true,
syncError: true,
autoSync: true,
syncFailureCount: true,
notifiedSyncError: true,
notifiedCiState: true,
notifiedRelease: true,
webhookLastAt: true,
createdAt: true,
updatedAt: true,
connection: { select: connectionSafeSelect },
snapshot: true,
});
/**
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
*/
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
repositories: {
include: { connection: { select: connectionSafeSelect }, snapshot: true },
select: repositorySafeSelect,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
});
/**
* IDs der Repositories eines Projekts, fuer die ein Webhook hinterlegt ist.
* Getrennt geholt, damit das Geheimnis selbst nirgends mitgereicht wird.
*/
export async function webhookRepositoryIds(projectId: string): Promise<string[]> {
const treffer = await prisma.repository.findMany({
where: { projectId, webhookSecret: { not: null } },
select: { id: true },
});
return treffer.map((eintrag) => eintrag.id);
}
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
...repositoryInclude,
_count: { select: { notes: true, tasks: true } },
+48
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
DUE_BUCKET_META,
DUE_BUCKET_ORDER,
TASK_STATUS_META,
TASK_STATUS_ORDER,
dueBucket,
nextDueDate,
progressFromTasks,
} from "./tasks";
@@ -88,3 +91,48 @@ describe("nextDueDate", () => {
expect(Number.isNaN(next.getTime())).toBe(false);
});
});
describe("dueBucket", () => {
/** 14.08.2026, 17:30 Berliner Zeit – bewusst spaeter Nachmittag. */
const JETZT = new Date("2026-08-14T15:30:00Z");
it("ordnet eine Aufgabe ohne Termin nach hinten", () => {
expect(dueBucket(null, JETZT)).toBe("someday");
});
it("zaehlt den heutigen Tag als heute, auch wenn die Uhrzeit vorbei ist", () => {
// Der Punkt der Funktion: gerechnet wird auf Kalendertage. Um 9 Uhr
// faellig und jetzt ist es halb sechs – die Aufgabe steht trotzdem
// unter "Heute" und nicht unter "Ueberfaellig".
expect(dueBucket(new Date("2026-08-14T07:00:00Z"), JETZT)).toBe("today");
});
it("erkennt den Vortag als ueberfaellig", () => {
expect(dueBucket(new Date("2026-08-13T21:00:00Z"), JETZT)).toBe("overdue");
});
it("fasst die kommenden sieben Tage als diese Woche", () => {
expect(dueBucket(new Date("2026-08-15T06:00:00Z"), JETZT)).toBe("week");
expect(dueBucket(new Date("2026-08-21T06:00:00Z"), JETZT)).toBe("week");
});
it("schiebt alles danach nach spaeter", () => {
expect(dueBucket(new Date("2026-08-22T06:00:00Z"), JETZT)).toBe("later");
});
it("richtet sich nach Berliner Kalendertagen, nicht nach UTC", () => {
// 21:30 UTC ist in Berlin schon 23:30 desselben Tages – im Container
// laeuft die Anwendung aber nach UTC. Ohne feste Zeitzone kippte die
// Einteilung hier um einen Tag.
const spaetAbends = new Date("2026-08-15T21:30:00Z");
expect(dueBucket(new Date("2026-08-15T10:00:00Z"), spaetAbends)).toBe("today");
expect(dueBucket(new Date("2026-08-16T10:00:00Z"), spaetAbends)).toBe("week");
});
it("hat zu jedem Fach eine Beschriftung und eine Farbe", () => {
for (const bucket of DUE_BUCKET_ORDER) {
expect(DUE_BUCKET_META[bucket].label).toBeTruthy();
expect(DUE_BUCKET_META[bucket].color).toMatch(/^#[0-9a-f]{6}$/i);
}
});
});
+62
View File
@@ -161,6 +161,68 @@ export async function spawnRecurrence(taskId: string) {
});
}
// ── Faelligkeit in der Gesamtuebersicht ──────────────────────
/**
* Faecher der Aufgabenuebersicht. Anders als im Projektbrett ordnet dort
* nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist
* ueberfaellig" die Frage, nicht "was steht in welcher Spalte".
*/
export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const;
export type DueBucket = (typeof DUE_BUCKET_ORDER)[number];
export const DUE_BUCKET_META: Record<DueBucket, { label: string; color: string }> = {
overdue: { label: "Überfällig", color: "#fb7185" },
today: { label: "Heute", color: "#fbbf24" },
week: { label: "Diese Woche", color: "#a78bfa" },
later: { label: "Später", color: "#38bdf8" },
someday: { label: "Ohne Termin", color: "#8a98b0" },
};
/**
* Kalendertag in fester Zeitzone, als fortlaufende Tagesnummer.
*
* Die Serverzeit taugt dafuer nicht: im Container laeuft die Anwendung nach
* UTC. Um 23:30 Berliner Zeit ist dort noch der Vortag, und eine heute
* faellige Aufgabe stuende unter "Ueberfaellig". Dieselbe Zeitzone benutzt
* auch die Datumsausgabe in lib/utils.
*/
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
function tagesnummer(wert: Date): number {
// en-CA liefert "2026-08-14" – und damit etwas, das sich ohne weiteres
// Zerlegen sortieren und rechnen laesst.
const [jahr, monat, tag] = TAG_FORMAT.format(wert).split("-").map(Number);
return Date.UTC(jahr, monat - 1, tag) / 86_400_000;
}
/**
* In welches Fach eine Aufgabe faellt.
*
* Gerechnet wird auf Kalendertage, nicht auf Stunden: eine Aufgabe, die
* heute um 9 Uhr faellig war, ist um 17 Uhr nicht "ueberfaellig", sondern
* immer noch "heute". Sonst wanderte die halbe Liste im Lauf des Tages
* nach oben.
*
* Bewusst als reine Funktion mit uebergebenem "jetzt" – das macht sie
* pruefbar.
*/
export function dueBucket(dueDate: Date | null, now: Date = new Date()): DueBucket {
if (!dueDate) return "someday";
const abstand = tagesnummer(dueDate) - tagesnummer(now);
if (abstand < 0) return "overdue";
if (abstand === 0) return "today";
if (abstand <= 7) return "week";
return "later";
}
/** Naechste freie Position am Ende einer Spalte. */
export async function nextPosition(
projectId: string,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { issueShouldBeOpen, statusForIssueState } from "./issues";
describe("issueShouldBeOpen", () => {
it("haelt das Issue offen, solange die Aufgabe nicht erledigt ist", () => {
expect(issueShouldBeOpen("TODO")).toBe(true);
expect(issueShouldBeOpen("DOING")).toBe(true);
expect(issueShouldBeOpen("BLOCKED")).toBe(true);
});
it("schliesst es erst bei ERLEDIGT", () => {
expect(issueShouldBeOpen("DONE")).toBe(false);
});
});
describe("statusForIssueState", () => {
it("macht aus einem geschlossenen Issue eine erledigte Aufgabe", () => {
expect(statusForIssueState(false, "TODO")).toBe("DONE");
expect(statusForIssueState(false, "DOING")).toBe("DONE");
});
it("holt eine erledigte Aufgabe zurueck nach OFFEN", () => {
expect(statusForIssueState(true, "DONE")).toBe("TODO");
});
it("laesst den feineren Zustand stehen, wenn das Issue ohnehin offen ist", () => {
// Ein "opened"-Ereignis zu einer Aufgabe, die schon in Arbeit ist, darf
// sie nicht auf OFFEN zuruecksetzen – IN ARBEIT ist mehr Information.
expect(statusForIssueState(true, "DOING")).toBe("DOING");
expect(statusForIssueState(true, "BLOCKED")).toBe("BLOCKED");
expect(statusForIssueState(true, "TODO")).toBe("TODO");
});
it("bildet den Rundlauf ab, so verlustbehaftet wie er ist", () => {
// IN ARBEIT geht nach draussen als "offen". Kommt es zurueck, bleibt es
// IN ARBEIT – der Zustand ueberlebt also, solange niemand das Issue
// schliesst. Nach dem Schliessen und Wiederoeffnen ist er weg, und das
// ist die bewusst hingenommene Grenze.
const hin = issueShouldBeOpen("DOING");
expect(statusForIssueState(hin, "DOING")).toBe("DOING");
expect(statusForIssueState(true, statusForIssueState(false, "DOING"))).toBe("TODO");
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { TaskStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git";
/**
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
*
* Die Zustaende passen nicht aufeinander und das bleibt so: Nebula kennt
* OFFEN, IN ARBEIT, BLOCKIERT und ERLEDIGT, ein Issue nur offen oder
* geschlossen. Nach aussen geht deshalb nur die Grenze zwischen "erledigt"
* und "nicht erledigt"; ein Rundlauf verliert IN ARBEIT und BLOCKIERT.
*
* Die Alternative waeren Labels wie "nebula:doing" – die wuerden den
* Issue-Tracker aber mit Zustaenden fuellen, die dort niemand pflegt. Die
* feine Abstufung bleibt in Nebula.
*/
/** Ist der Aufgabenzustand einer, bei dem das Issue offen sein soll? */
export function issueShouldBeOpen(status: TaskStatus): boolean {
return status !== "DONE";
}
/** Der Aufgabenzustand, der zu einem Issue-Zustand passt. */
export function statusForIssueState(open: boolean, current: TaskStatus): TaskStatus {
if (open) {
// Ein wieder geoeffnetes Issue macht aus ERLEDIGT wieder OFFEN. Stand
// die Aufgabe ohnehin nicht auf ERLEDIGT, bleibt ihr feinerer Zustand
// stehen – IN ARBEIT ist mehr Information als OFFEN.
return current === "DONE" ? "TODO" : current;
}
return "DONE";
}
/**
* Traegt den Zustand einer Aufgabe an ihrem Issue nach.
*
* Bewusst fehlertolerant: schlaegt der Aufruf fehl, ist die Aufgabe in
* Nebula trotzdem umgezogen. Ein nicht erreichbares Git-System darf das
* Abhaken nicht verhindern – der naechste Wechsel versucht es erneut.
*
* Gibt zurueck, ob es geklappt hat; null heisst "nichts zu tun".
*/
export async function mirrorStatusToIssue(
taskId: string,
status: TaskStatus,
): Promise<boolean | null> {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { sourceIssue: true, sourceRepositoryId: true },
});
if (!task?.sourceIssue || !task.sourceRepositoryId) return null;
const repository = await prisma.repository.findUnique({
where: { id: task.sourceRepositoryId },
include: { connection: true },
});
if (!repository?.connection || !repository.repoOwner || !repository.repoName) return null;
try {
const client = clientForConnection(repository.connection);
await client.setIssueState(
{ owner: repository.repoOwner, name: repository.repoName },
task.sourceIssue,
issueShouldBeOpen(status),
);
return true;
} catch {
return false;
}
}
+137
View File
@@ -0,0 +1,137 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { parseEvent, verifySignature } from "./verify";
const GEHEIM = "ein-geheimnis-das-lang-genug-ist";
const RUMPF = JSON.stringify({ action: "closed", issue: { number: 12 } });
function signiere(secret: string, body: string): string {
return createHmac("sha256", secret).update(body, "utf8").digest("hex");
}
function kopf(eintraege: Record<string, string>): Headers {
return new Headers(eintraege);
}
describe("verifySignature – GitHub", () => {
it("nimmt die richtige Signatur an", () => {
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("weist eine fremde Signatur ab", () => {
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere("anderes", RUMPF)}` });
expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({
ok: false,
reason: "mismatch",
});
});
it("weist ab, wenn der Rumpf veraendert wurde", () => {
// Das ist der eigentliche Zweck: die Signatur haengt am Inhalt.
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
const veraendert = JSON.stringify({ action: "closed", issue: { number: 99 } });
expect(verifySignature("GITHUB", GEHEIM, veraendert, headers).ok).toBe(false);
});
it("weist ab, wenn gar keine Signatur mitkommt", () => {
expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({}))).toEqual({
ok: false,
reason: "missing",
});
});
it("laesst sich nicht mit dem blossen Praefix taeuschen", () => {
expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({ "x-hub-signature-256": "sha256=" })).ok).toBe(
false,
);
});
});
describe("verifySignature – Gitea", () => {
it("nimmt die Signatur ohne Praefix an", () => {
const headers = kopf({ "x-gitea-signature": signiere(GEHEIM, RUMPF) });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("nimmt ersatzweise das GitHub-Format an", () => {
// Aeltere Gitea-Fassungen senden nur X-Hub-Signature-256.
const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true });
});
it("weist eine falsche Signatur ab", () => {
const headers = kopf({ "x-gitea-signature": signiere("anderes", RUMPF) });
expect(verifySignature("GITEA", GEHEIM, RUMPF, headers).ok).toBe(false);
});
});
describe("verifySignature – GitLab", () => {
it("vergleicht den Token im Klartext", () => {
expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": GEHEIM }))).toEqual({
ok: true,
});
});
it("weist einen falschen Token ab", () => {
expect(
verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": "falsch" })).ok,
).toBe(false);
});
it("weist ab, wenn der Token fehlt", () => {
expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({}))).toEqual({
ok: false,
reason: "missing",
});
});
});
describe("parseEvent", () => {
it("erkennt einen Push", () => {
expect(parseEvent("GITHUB", "push", {})).toEqual({ kind: "push" });
});
it("erkennt ein geschlossenes Issue bei GitHub und Gitea", () => {
const nutzlast = { action: "closed", issue: { number: 12 } };
expect(parseEvent("GITHUB", "issues", nutzlast)).toEqual({
kind: "issue",
number: 12,
open: false,
});
expect(parseEvent("GITEA", "issues", nutzlast)).toEqual({
kind: "issue",
number: 12,
open: false,
});
});
it("erkennt ein wieder geoeffnetes Issue", () => {
expect(parseEvent("GITHUB", "issues", { action: "reopened", issue: { number: 7 } })).toEqual({
kind: "issue",
number: 7,
open: true,
});
});
it("nimmt bei GitLab die projektinterne Nummer", () => {
// "iid" ist die Nummer aus der Oberflaeche; "id" waere instanzweit.
const ereignis = parseEvent("GITLAB", "Issue Hook", {
object_kind: "issue",
object_attributes: { iid: 5, state: "closed" },
});
expect(ereignis).toEqual({ kind: "issue", number: 5, open: false });
});
it("laesst Unbekanntes liegen, statt zu scheitern", () => {
expect(parseEvent("GITHUB", "issue_comment", {}).kind).toBe("ignored");
expect(parseEvent("GITHUB", "issues", { action: "labeled", issue: { number: 1 } }).kind).toBe(
"ignored",
);
expect(parseEvent("GITHUB", null, null).kind).toBe("ignored");
});
it("laesst ein Issue ohne Nummer liegen", () => {
expect(parseEvent("GITHUB", "issues", { action: "closed", issue: {} }).kind).toBe("ignored");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { GitProvider } from "@prisma/client";
/**
* Pruefung eingehender Webhooks.
*
* Der Endpunkt ist die einzige Stelle der Anwendung, die ohne Anmeldung
* schreibend wirkt – deshalb steht die Pruefung hier fuer sich, ohne
* Datenbank und ohne Netz, und ist damit vollstaendig testbar.
*
* Die drei Systeme machen es unterschiedlich:
*
* GitHub X-Hub-Signature-256: "sha256=<hex>", HMAC-SHA256 ueber den Rumpf
* Gitea X-Gitea-Signature: "<hex>" ohne Praefix, sonst gleich
* GitLab X-Gitlab-Token: das Geheimnis im Klartext, keine Signatur
*
* GitLabs Verfahren ist schwaecher – es beweist nur, dass der Absender das
* Geheimnis kennt, nicht dass der Rumpf unveraendert ist. Mehr gibt die
* Plattform nicht her; verglichen wird trotzdem zeitkonstant.
*/
export type VerifyResult =
| { ok: true }
| { ok: false; reason: "missing" | "mismatch" | "unsupported" };
/** Vergleich ohne Laufzeitunterschied – ein Zeichenvergleich waere angreifbar. */
function equals(a: string, b: string): boolean {
const links = Buffer.from(a, "utf8");
const rechts = Buffer.from(b, "utf8");
// timingSafeEqual verlangt gleiche Laenge und wirft sonst. Die Laenge
// selbst ist kein Geheimnis, der fruehe Ausstieg also unbedenklich.
if (links.length !== rechts.length) return false;
return timingSafeEqual(links, rechts);
}
function hmacHex(secret: string, body: string): string {
return createHmac("sha256", secret).update(body, "utf8").digest("hex");
}
/**
* Prueft die Signatur eines eingehenden Aufrufs.
*
* `body` muss der **rohe** Rumpf sein, genau wie er ankam. Ein durch
* JSON.parse und JSON.stringify gedrehter Rumpf hat eine andere Signatur –
* andere Reihenfolge, anderes Escaping, kein Leerraum.
*/
export function verifySignature(
provider: GitProvider,
secret: string,
body: string,
headers: Headers,
): VerifyResult {
switch (provider) {
case "GITHUB": {
const gesendet = headers.get("x-hub-signature-256");
if (!gesendet) return { ok: false, reason: "missing" };
const erwartet = `sha256=${hmacHex(secret, body)}`;
return equals(gesendet, erwartet) ? { ok: true } : { ok: false, reason: "mismatch" };
}
case "GITEA": {
// Gitea schickt die Signatur ohne Praefix. Aeltere Fassungen senden
// zusaetzlich X-Hub-Signature-256 im GitHub-Format – beides gilt.
const gitea = headers.get("x-gitea-signature");
if (gitea) {
return equals(gitea, hmacHex(secret, body))
? { ok: true }
: { ok: false, reason: "mismatch" };
}
const hub = headers.get("x-hub-signature-256");
if (hub) {
return equals(hub, `sha256=${hmacHex(secret, body)}`)
? { ok: true }
: { ok: false, reason: "mismatch" };
}
return { ok: false, reason: "missing" };
}
case "GITLAB": {
const token = headers.get("x-gitlab-token");
if (!token) return { ok: false, reason: "missing" };
return equals(token, secret) ? { ok: true } : { ok: false, reason: "mismatch" };
}
default:
return { ok: false, reason: "unsupported" };
}
}
/** Welches Ereignis kam an – auf einen gemeinsamen Nenner gebracht. */
export type WebhookEvent =
| { kind: "push" }
| { kind: "issue"; number: number; open: boolean }
| { kind: "ignored"; name: string };
interface IssuePayload {
action?: string;
issue?: { number?: number; iid?: number; state?: string };
object_attributes?: { iid?: number; action?: string; state?: string };
object_kind?: string;
}
/**
* Uebersetzt den Rumpf in ein Ereignis, das die Anwendung kennt.
*
* Bewusst grob: Nebula interessiert nur, ob ein Push kam (dann lohnt ein
* Abgleich) und ob ein Issue geschlossen oder wieder geoeffnet wurde (dann
* wandert die Aufgabe mit). Alles andere wird benannt und liegen gelassen.
*/
export function parseEvent(
provider: GitProvider,
eventName: string | null,
payload: unknown,
): WebhookEvent {
const daten = (payload ?? {}) as IssuePayload;
const name = (eventName ?? daten.object_kind ?? "").toLowerCase();
if (name === "push" || name === "push hook") return { kind: "push" };
if (provider === "GITLAB") {
if (name !== "issue" && name !== "issue hook") return { kind: "ignored", name };
const attrs = daten.object_attributes;
const nummer = attrs?.iid;
if (typeof nummer !== "number") return { kind: "ignored", name };
// GitLab meldet den Zustand als "opened" oder "closed".
const offen = attrs?.state !== "closed";
return { kind: "issue", number: nummer, open: offen };
}
// GitHub und Gitea nennen das Ereignis "issues" und legen die Tat in
// "action" ab. Umbenennungen und Kommentare interessieren hier nicht.
if (name !== "issues") return { kind: "ignored", name };
const nummer = daten.issue?.number;
if (typeof nummer !== "number") return { kind: "ignored", name };
if (daten.action !== "closed" && daten.action !== "reopened" && daten.action !== "opened") {
return { kind: "ignored", name: `issues.${daten.action ?? "?"}` };
}
return { kind: "issue", number: nummer, open: daten.action !== "closed" };
}
+13 -2
View File
@@ -19,7 +19,13 @@ const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"];
/**
* Eingehende Webhooks bringen kein Cookie mit – sie kommen von GitHub,
* GitLab oder Gitea. Ausgewiesen wird sich dort mit einer Signatur ueber den
* Rumpf, geprueft im Endpunkt selbst (lib/webhooks/verify). Ohne gueltiges
* Geheimnis passiert nichts.
*/
const PUBLIC_API_PREFIXES = ["/api/auth/", "/api/webhooks/"];
/**
* Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
@@ -91,7 +97,12 @@ export function middleware(request: NextRequest) {
const isDev = process.env.NODE_ENV === "development";
// ── 1. CSRF ────────────────────────────────────────────────
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
// Webhooks kommen von einem fremden Server und koennen keinen passenden
// Origin mitbringen. Die Herkunftspruefung schuetzt gegen den Browser
// eines angemeldeten Nutzers – hier gibt es keinen, und ausgewiesen wird
// sich stattdessen mit der Signatur ueber den Rumpf.
const istWebhook = pathname.startsWith("/api/webhooks/");
if (!istWebhook && !SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
return NextResponse.json(
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
{ status: 403 },
+32 -2
View File
@@ -4,12 +4,14 @@ import type {
Note,
Project,
ProjectStatus,
Recurrence,
RepoSnapshot,
Repository,
Task,
TaskStatus,
} from "@prisma/client";
import type { CommitCalendar } from "@/lib/git/calendar";
import type { DueBucket } from "@/lib/tasks";
import type {
BranchInfo,
CiInfo,
@@ -38,8 +40,14 @@ export interface ConnectionWithCount extends ConnectionSafe {
_count?: { repositories: number };
}
/** Ein Repository samt Verbindung und zuletzt geholtem Stand. */
export interface RepositoryWithSnapshot extends Repository {
/**
* Ein Repository samt Verbindung und zuletzt geholtem Stand.
*
* Ohne webhookSecret: das Geheimnis verlaesst den Server nicht – siehe
* repositorySafeSelect. Ob einer eingerichtet ist, reicht die Projektseite
* getrennt durch.
*/
export interface RepositoryWithSnapshot extends Omit<Repository, "webhookSecret"> {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
}
@@ -70,6 +78,28 @@ export interface ProjectDetail extends Project {
_count: { notes: number; tasks: number };
}
/**
* Eine Aufgabe in der projektuebergreifenden Uebersicht.
*
* Bewusst kein `extends Task`: die Seite braucht das Projekt dazu, das Fach
* fuer die Faelligkeit kommt vom Server, und Datumsangaben gehen als
* Zeichenkette an den Browser – ein Date ueberlebt die Serialisierung nicht.
*/
export interface TaskOverviewItem {
id: string;
title: string;
description: string | null;
status: TaskStatus;
dueDate: string | null;
labels: string[];
recurrence: Recurrence | null;
/** Gesetzt, wenn die Aufgabe an einem Git-Issue haengt. */
sourceIssue: number | null;
sourceUrl: string | null;
bucket: DueBucket;
project: { id: string; name: string; accent: string; status: ProjectStatus };
}
export interface RepoListEntry {
fullPath: string;
owner: string;