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

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

Zwei Dinge, die sonst still falsch werden:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 07:32:04 +02:00
36 changed files with 1599 additions and 92 deletions
+6 -1
View File
@@ -69,9 +69,14 @@ ADMINER_PORT="127.0.0.1:8080"
# und Passkeys sind davon nicht betroffen.
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
# Lebensdauer einer Anmeldung in Tagen.
# Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet
# und nicht verlaengerbar.
SESSION_TTL_DAYS="30"
# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben.
# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab.
SESSION_IDLE_HOURS="8"
# ── Benachrichtigungen ───────────────────────────────────────
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
# Anwendung unter "Mein Konto" eingerichtet.
+49 -3
View File
@@ -22,8 +22,19 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
### 0.2 Anmeldung
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden
abgelehnt. Hashing mit scrypt.
- **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende
Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`),
durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält.
**Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu
`Passwort1!` führen statt zu Entropie.
- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat
Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben
also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand
gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt.
- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt,
deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort
stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit
(`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen.
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
@@ -518,7 +529,32 @@ soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledig
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.
**Git → Nebula** auf zwei Wegen, die sich ergänzen:
- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz
von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7).
- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand
verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der
Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein
Haken einmal ausfällt.
Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt,
werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen
Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang
fragt der Abgleich höchstens 25 Issues nach.
**Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse:
ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen
ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder
aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“
sieht.
Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen.
In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der
Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der
Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan,
sondern das Git-System gemeldet).
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
@@ -760,6 +796,16 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
WebAuthn-Optionen.
- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die
Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden
und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab
Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`).
Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue
aus.
- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und
dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen,
dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht
überschreiben und keine Sitzung unterschieben.
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
+2 -1
View File
@@ -118,7 +118,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
| `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` |
| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` |
| `PORT` | Port der Anwendung | `3000` |
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
+128
View File
@@ -1,4 +1,13 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
/**
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
@@ -279,6 +288,125 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
await login(request, ADMIN);
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title: "Haengt an einem geschlossenen Issue" },
});
const taskId = (await angelegt.json()).task.id;
/**
* Die Verknuepfung direkt in der Datenbank setzen.
*
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
*
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
*/
const prisma = new PrismaClient({
datasources: { db: { url: DATABASE_URL } },
});
try {
await prisma.task.update({
where: { id: taskId },
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
});
} finally {
await prisma.$disconnect();
}
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const alsAngelegt = vorher.project.tasks.find(
(t: { id: string }) => t.id === taskId,
);
expect(alsAngelegt.sourceIssue).toBe(1);
expect(alsAngelegt.status).toBe("TODO");
// Abgleich anstossen – er fragt den Zustand des Issues nach.
const sync = await request.post(`/api/repositories/${repoId}/sync`);
const syncBody = await sync.json();
/**
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
*/
if (!sync.ok()) {
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
return;
}
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
expect(abgehakt.status).toBe("DONE");
expect(abgehakt.doneAt).not.toBeNull();
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
// ohne dass erneut nachgefragt wird.
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN);
+26
View File
@@ -223,6 +223,32 @@ test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page
).toBeVisible();
});
test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => {
// Erreichbar vom Dashboard aus, ohne vierten Punkt in der Kopfleiste.
await page.getByRole("link", { name: /Wochenrückblick/ }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
await expect(page.getByRole("heading", { name: "Diese Woche", level: 1 })).toBeVisible();
// Die Vorgaenge der bisherigen Tests liegen in dieser Woche, es muss also
// etwas dastehen – und das Projekt gehoert an jede Zeile.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
// Blättern: zurück und wieder her.
await page.getByRole("link", { name: "Eine Woche zurück" }).click();
await expect(page).toHaveURL(/woche=-1/);
await expect(page.getByRole("heading", { name: "Vorige Woche", level: 1 })).toBeVisible();
await page.getByRole("link", { name: "Diese Woche", exact: true }).click();
await expect(page).toHaveURL(/\/rueckblick$/);
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Rückblick");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/rueckblick/);
});
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.7.0",
"version": "1.9.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
}
}
const weak = validatePasswordStrength(parsed.data.newPassword);
const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
}
+1 -1
View File
@@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) {
}
if (password) {
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, target.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+36 -14
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
@@ -42,6 +42,30 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401);
}
/**
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
*
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
* die der einheitliche Text GENERIC sonst verhindert.
*
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
*/
const valid = user.passwordHash
? await verifyPassword(password, user.passwordHash)
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
await fakeVerifyDelay().then(() => false);
if (!valid) {
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
if (user.passwordHash) await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) {
return jsonError(
@@ -51,22 +75,20 @@ export async function POST(request: Request) {
}
if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
if (!user.passwordHash) {
await fakeVerifyDelay();
return jsonError(
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
401,
);
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
/**
* Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
* anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
* bekommt hier still einen neuen – es ist der einzige Moment, in dem das
* Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
* die halbe Sekunde wartet.
*/
if (needsRehash(user.passwordHash)) {
void hashPassword(password)
.then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
.catch(() => undefined);
}
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
+1 -1
View File
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json(
{ error: weak, fields: { password: weak } },
+12 -6
View File
@@ -3,7 +3,7 @@ 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 { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues";
import { refreshDerivedProgress } from "@/lib/tasks";
import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
@@ -118,13 +118,19 @@ export async function POST(request: Request, { params }: Params) {
}
const ziel = statusForIssueState(ereignis.open, task.status);
if (ziel !== task.status) {
if (ziel === "DONE" && task.status !== "DONE") {
// Ueber die gemeinsame Funktion, damit ein geschlossenes Issue
// dasselbe bewirkt wie ein Haken in der Oberflaeche: naechste Fassung
// einer wiederkehrenden Aufgabe und ein Eintrag im Verlauf.
await completeTaskFromIssue(task.id, "webhook");
} else if (ziel !== task.status) {
// Wieder geoeffnet. Das darf nur der Webhook, weil hier ein echtes
// "reopened" vorliegt – der Abgleich sieht nur Zustaende und wuerde
// sonst eine bewusst erledigte Aufgabe aufmachen.
await prisma.task.update({
where: { id: task.id },
data: {
status: ziel,
doneAt: ziel === "DONE" ? new Date() : null,
},
data: { status: ziel, doneAt: null },
});
await refreshDerivedProgress(task.projectId);
}
+11 -1
View File
@@ -1,4 +1,5 @@
import { CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
import Link from "next/link";
import { CalendarRange, CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
import { ProjectBoard } from "@/components/ProjectBoard";
import { StatCard } from "@/components/StatCard";
import { requireUserPage } from "@/lib/auth/guard";
@@ -50,6 +51,15 @@ export default async function DashboardPage() {
Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den
verknuepften Repositories bei GitHub, GitLab und Gitea passiert.
</p>
{/* Kein weiterer Punkt in der Kopfleiste – die ist auf dem Telefon
schon eng. Der Rueckblick haengt dort, wo man ohnehin hinsieht. */}
<Link
href="/rueckblick"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-violet-300 underline-offset-2 hover:underline"
>
<CalendarRange className="h-4 w-4" />
Wochenrückblick ansehen
</Link>
</header>
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
+10 -2
View File
@@ -85,8 +85,16 @@ export default async function ProjectPage({ params }: PageProps) {
progressFromTasks={detail.progressFromTasks}
/>
{/*
min-w-0 an beiden Spalten ist hier nicht Kosmetik, sondern der Kern:
ein Rasterkind hat "min-width: auto" und weigert sich damit, unter
die Inhaltsbreite seines breitesten Kindes zu schrumpfen. Die
Heatmap ist ueber 700px breit – auf dem Telefon zog sie damit die
ganze Spalte und mit ihr die Seite auseinander. Erst mit min-w-0
greift das overflow-x-auto darin ueberhaupt.
*/}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-5">
<div className="min-w-0 space-y-6 lg:col-span-5">
<section className="glass p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<FileText className="h-4 w-4 text-cyan-300" />
@@ -115,7 +123,7 @@ export default async function ProjectPage({ params }: PageProps) {
<NotesPanel projectId={detail.id} notes={detail.notes} />
</div>
<div className="space-y-6 lg:col-span-7">
<div className="min-w-0 space-y-6 lg:col-span-7">
<GitPanel
project={detail}
connections={connections as unknown as ConnectionSafe[]}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, CalendarRange } from "lucide-react";
import { WeeklyReview } from "@/components/WeeklyReview";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { tagesschluessel, wocheUm, wochenName, wochenTitel, zaehleWoche } from "@/lib/review";
import type { ReviewDay, ReviewEntry } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Wochenrückblick" };
/** Wie weit sich zurueckblaettern laesst – der Verlauf haelt ohnehin nur 200 je Projekt. */
const MAX_RUECKBLICK = 25;
export default async function ReviewPage({
searchParams,
}: {
searchParams: Promise<{ woche?: string }>;
}) {
const user = await requireUserPage();
const params = await searchParams;
// Nur nach hinten blaettern: die Zukunft ist im Rueckblick leer.
const gewuenscht = Number(params.woche ?? 0);
const versatz = Number.isFinite(gewuenscht)
? Math.min(0, Math.max(-MAX_RUECKBLICK, Math.trunc(gewuenscht)))
: 0;
const woche = wocheUm(new Date(), versatz);
/**
* Der Verlauf kennt keinen Besitzer – die Trennung laeuft ueber das
* Projekt. Die Bedingung steht deshalb als erste in der where-Klausel.
*/
const activities = await prisma.activity.findMany({
where: {
project: { ownerId: user.id },
createdAt: { gte: woche.von, lt: woche.bis },
},
include: {
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
// meta kommt mit, weil die Kennzahlen daran ablesen, was erledigt
// wurde – verlaesslicher als der Anzeigetext.
orderBy: { createdAt: "desc" },
// Deckel gegen eine Woche, in der ein Abgleich Amok gelaufen ist.
take: 500,
});
/**
* Nach Kalendertagen buendeln – auf dem Server, damit Server-Render und
* Hydration nicht auseinanderlaufen. "Welcher Tag" haengt an der Zeitzone.
*/
const nachTag = new Map<string, ReviewEntry[]>();
for (const eintrag of activities) {
const schluessel = tagesschluessel(eintrag.createdAt);
const liste = nachTag.get(schluessel) ?? [];
liste.push({
id: eintrag.id,
kind: eintrag.kind,
summary: eintrag.summary,
createdAt: eintrag.createdAt.toISOString(),
project: eintrag.project,
user: eintrag.user,
});
nachTag.set(schluessel, liste);
}
// Neuester Tag zuerst, und nur Tage, an denen etwas war.
const tage: ReviewDay[] = [...woche.tage]
.reverse()
.map((tag) => ({ tag, eintraege: nachTag.get(tag) ?? [] }))
.filter((eintrag) => eintrag.eintraege.length > 0);
const kennzahlen = zaehleWoche(activities);
return (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{wochenName(woche)}
</h1>
<p className="mt-1 flex items-center gap-1.5 text-sm text-slate-400">
<CalendarRange className="h-3.5 w-3.5" />
{wochenTitel(woche)}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wählen">
<Link
href={`/rueckblick?woche=${versatz - 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche zurück"
// Am Ende des Zeitraums bleibt der Verweis stehen, wird aber
// wirkungslos – die Seite klemmt den Wert ohnehin.
aria-disabled={versatz <= -MAX_RUECKBLICK}
>
<ArrowLeft className="h-3.5 w-3.5" />
Früher
</Link>
{versatz < 0 ? (
<Link
href={`/rueckblick?woche=${versatz + 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche vor"
>
Später
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
{versatz !== 0 ? (
<Link href="/rueckblick" className="btn btn-ghost px-2.5 py-1.5 text-xs">
Diese Woche
</Link>
) : null}
</nav>
</div>
</header>
<WeeklyReview
tage={tage}
kennzahlen={kennzahlen}
istDieseWoche={versatz === 0}
/>
</div>
);
}
+4 -33
View File
@@ -1,23 +1,9 @@
"use client";
import { useState } from "react";
import {
AlertTriangle,
ArrowRight,
Bot,
Download,
GitCommitHorizontal,
History,
Link2,
Loader2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityEntry, ActivityKind } from "@/types";
import { Bot, History, Loader2 } from "lucide-react";
import { ACTIVITY_KINDS } from "@/components/activityKinds";
import type { ActivityEntry } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils";
/**
@@ -29,21 +15,6 @@ import { formatDateTime, formatRelative } from "@/lib/utils";
* obersten fuenf liest.
*/
const KINDS: Record<ActivityKind, { icon: typeof Sparkles; color: string }> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" },
};
export function ActivityFeed({
projectId,
@@ -88,7 +59,7 @@ export function ActivityFeed({
) : (
<ol className="mt-4 space-y-0">
{entries.map((entry, index) => {
const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED;
const meta = ACTIVITY_KINDS[entry.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = meta.icon;
const letzter = index === entries.length - 1;
+8
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
CalendarRange,
CheckSquare,
CornerDownLeft,
GitBranch,
@@ -56,6 +57,13 @@ interface Action {
const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
{
id: "rueckblick",
label: "Wochenrückblick",
hint: "Was diese Woche passiert ist",
icon: CalendarRange,
href: "/rueckblick",
},
{
id: "tasks",
label: "Aufgaben",
+197
View File
@@ -0,0 +1,197 @@
import Link from "next/link";
import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react";
import { ACTIVITY_KINDS } from "@/components/activityKinds";
import type { Kennzahlen } from "@/lib/review";
import { accentOf } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Der Wochenrueckblick.
*
* Bewusst eine Server-Komponente: es gibt nichts zu bedienen, alles steht
* beim Rendern fest. Das spart dem Browser den ganzen Verlauf als JavaScript.
*
* Die Tage kommen fertig gebuendelt und in der richtigen Reihenfolge – die
* Zuordnung "welcher Kalendertag" haengt an der Zeitzone und gehoert
* deshalb auf den Server.
*/
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
weekday: "long",
});
const TAG_MONAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
function alsDatum(tag: string): Date {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t, 12));
}
export function WeeklyReview({
tage,
kennzahlen,
istDieseWoche,
}: {
tage: ReviewDay[];
/** Fertig gezaehlt vom Server – die Anzeige rechnet nichts nach. */
kennzahlen: Kennzahlen;
istDieseWoche: boolean;
}) {
const { gesamt, projekte, erledigt, neueAufgaben, notizen } = kennzahlen;
if (gesamt === 0) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<CalendarRange className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{istDieseWoche ? "Diese Woche war es still" : "In dieser Woche ist nichts passiert"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{istDieseWoche
? "Sobald du Aufgaben abhakst, Notizen schreibst oder etwas gepusht wird, steht es hier."
: "Vielleicht lohnt ein Blick auf eine frühere Woche."}
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<Kennzahl label="Vorgänge" wert={gesamt} icon={CalendarRange} color="#a78bfa" />
<Kennzahl label="Projekte berührt" wert={projekte} icon={FolderGit2} color="#38bdf8" />
<Kennzahl label="Aufgaben erledigt" wert={erledigt} icon={CheckSquare} color="#34d399" />
<Kennzahl label="Neue Aufgaben" wert={neueAufgaben} icon={Plus} color="#fbbf24" />
</div>
{notizen > 0 ? (
<p className="flex items-center gap-1.5 px-1 text-xs text-slate-500">
<NotebookPen className="h-3.5 w-3.5" />
Dazu {notizen} {notizen === 1 ? "neue Notiz" : "neue Notizen"}.
</p>
) : null}
{/* Verlauf nach Tagen */}
<div className="space-y-6">
{tage.map((tag) => {
const datum = alsDatum(tag.tag);
return (
<section key={tag.tag}>
<div className="mb-2.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{WOCHENTAG.format(datum)}
</h2>
<span className="text-xs text-slate-500">{TAG_MONAT.format(datum)}</span>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{tag.eintraege.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{tag.eintraege.map((eintrag) => (
<Zeile key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
</div>
);
}
function Kennzahl({
label,
wert,
icon: Icon,
color,
}: {
label: string;
wert: number;
icon: typeof CalendarRange;
color: string;
}) {
return (
<div className="glass relative overflow-hidden p-3 sm:p-4">
<div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label}
</p>
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:text-3xl">
{wert}
</p>
</div>
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }}
>
<Icon className="h-4 w-4" />
</span>
</div>
</div>
);
}
function Zeile({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind];
const Icon = art.icon;
const accent = accentOf(eintrag.project.accent);
return (
<li className="flex items-start gap-2.5 px-3 py-2.5 sm:gap-3 sm:px-4">
<span className={cn("mt-0.5 shrink-0", art.color)} title={art.label}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{eintrag.summary}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${eintrag.project.id}`}
className="inline-flex max-w-[12rem] items-center gap-1.5 truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accent.from }}
aria-hidden="true"
/>
{eintrag.project.name}
</Link>
<span className="tabular-nums">{UHRZEIT.format(new Date(eintrag.createdAt))} Uhr</span>
{eintrag.user ? (
<span className="hidden xs:inline">
{eintrag.user.displayName?.trim() || eintrag.user.username}
</span>
) : (
// Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
<span className="hidden text-slate-600 xs:inline">automatisch</span>
)}
</div>
</div>
</li>
);
}
+43
View File
@@ -0,0 +1,43 @@
import {
AlertTriangle,
ArrowRight,
Download,
GitCommitHorizontal,
Link2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityKind } from "@prisma/client";
/**
* Symbol und Farbe je Art eines Verlaufseintrags.
*
* Ausgelagert, weil sie inzwischen an zwei Stellen gebraucht werden: im
* Verlauf eines Projekts und im Wochenrueckblick ueber alle Projekte. Zwei
* Kopien liefen beim naechsten neuen Eintragstyp auseinander.
*
* Bewusst ohne "use client" und ohne Prisma-Zugriff – nur Typen und
* Symbole, damit beide Seiten es importieren koennen.
*/
export const ACTIVITY_KINDS: Record<
ActivityKind,
{ icon: typeof Sparkles; color: string; label: string }
> = {
PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300", label: "Projekt angelegt" },
PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400", label: "Projekt bearbeitet" },
STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300", label: "Status geändert" },
NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300", label: "Notiz" },
NOTE_UPDATED: { icon: Pencil, color: "text-blue-300", label: "Notiz bearbeitet" },
NOTE_DELETED: { icon: Trash2, color: "text-rose-300", label: "Notiz gelöscht" },
TASK_ADDED: { icon: Plus, color: "text-emerald-300", label: "Aufgabe angelegt" },
TASK_MOVED: { icon: ArrowRight, color: "text-violet-300", label: "Aufgabe verschoben" },
TASK_DELETED: { icon: Trash2, color: "text-rose-300", label: "Aufgabe gelöscht" },
TASKS_IMPORTED: { icon: Download, color: "text-amber-300", label: "Aufgaben übernommen" },
REPO_LINKED: { icon: Link2, color: "text-cyan-300", label: "Repository verknüpft" },
REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300", label: "Commits" },
SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300", label: "Abgleich fehlgeschlagen" },
};
+2 -2
View File
@@ -46,7 +46,7 @@ export function setMfaCookie(response: NextResponse, token: string) {
name: MFA_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
sameSite: "strict",
secure: COOKIES_SECURE,
path: "/",
maxAge: Math.floor(MFA_TTL_MS / 1000),
@@ -58,7 +58,7 @@ export function clearMfaCookie(response: NextResponse) {
name: MFA_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
sameSite: "strict",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
+80 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PASSWORD_MIN_LENGTH } from "./constants";
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password";
describe("hashPassword und verifyPassword", () => {
it("erkennt das richtige Passwort wieder", async () => {
@@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => {
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
});
});
describe("Aufwand nach OWASP", () => {
it("hasht mit den heute vorgesehenen Parametern", async () => {
// OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1.
const hash = await hashPassword("ein-langes-testpasswort");
const [, N, r, p] = hash.split("$");
expect(Number(N)).toBe(131072);
expect(Number(r)).toBe(8);
expect(Number(p)).toBe(1);
});
it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => {
// Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben,
// sonst waere ein Anheben des Aufwands eine Aussperrung.
const alt =
"scrypt$32768$8$1$" +
Buffer.from("salzsalzsalzsalz").toString("base64url") +
"$";
// Echten Hash mit alten Parametern bauen:
const { scrypt } = await import("node:crypto");
const { promisify } = await import("node:util");
const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise<Buffer>;
const salt = Buffer.from("salzsalzsalzsalz");
const derived = await s("ein-langes-testpasswort", salt, 64, {
N: 32768,
r: 8,
p: 1,
maxmem: 96 * 1024 * 1024,
});
const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`;
expect(alt.length).toBeGreaterThan(0);
await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true);
// ... und wird als erneuerungsbeduerftig erkannt.
expect(needsRehash(alterHash)).toBe(true);
});
it("laesst frische Hashes in Ruhe", async () => {
expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false);
});
it("erkennt gleichwertige Kombinationen als ausreichend", () => {
// N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und
// ist nach OWASP ebenso zulaessig.
expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false);
// N=2^15 mit p=1 dagegen nicht – das war der alte Zustand.
expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true);
});
it("haelt Unsinn fuer erneuerungsbeduerftig", () => {
expect(needsRehash("voelliger-unsinn")).toBe(true);
expect(needsRehash(null)).toBe(false);
});
});
describe("Schwache Muster", () => {
it("weist Wiederholungen ab", () => {
expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy();
expect(validatePasswordStrength("abababababab")).toBeTruthy();
});
it("weist Zeichenfolgen ab", () => {
expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy();
expect(validatePasswordStrength("87654321abcd")).toBeTruthy();
});
it("weist Passwoerter mit dem Benutzernamen ab", () => {
expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/);
// Ohne Benutzernamen ist dasselbe Passwort in Ordnung.
expect(validatePasswordStrength("chefin-geheimnis")).toBeNull();
// Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles.
expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull();
});
it("laesst ein gutes Passwort mit Wiederholung durch", () => {
// Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist.
expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull();
});
});
+122 -10
View File
@@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as (
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
const PARAMS = { N: 32768, r: 8, p: 1 };
/**
* Arbeitsaufwand nach der Empfehlung des OWASP Password Storage Cheat Sheet:
* N = 2^17, r = 8, p = 1. Frueher stand hier N = 2^15 mit p = 1 – das liegt
* unter *jeder* der dort genannten zulaessigen Kombinationen (zu 2^15
* gehoerte p = 3) und war damit rund dreimal billiger zu knacken als
* vorgesehen.
*
* Gemessen kostet das rund 0,5 s je Pruefung und 128 MiB. Der Speicher
* klingt nach viel, ist aber gedeckelt: Node rechnet scrypt im
* libuv-Threadpool, standardmaessig vier Faeden parallel. Mehr als rund
* 512 MiB koennen also nicht gleichzeitig belegt sein, und davor liegen
* noch die Ratenbegrenzungen je IP und je Benutzername.
*/
const PARAMS = { N: 131072, r: 8, p: 1 };
const KEY_LENGTH = 64;
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
// dafuer nicht ganz.
const MAX_MEM = 96 * 1024 * 1024;
// scrypt braucht rund 128 * N * r Bytes – bei diesen Werten 128 MiB.
// Etwas Luft nach oben, damit die Pruefung nicht am Deckel scheitert.
const MAX_MEM = 192 * 1024 * 1024;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
@@ -72,6 +85,30 @@ export async function verifyPassword(
return timingSafeEqual(derived, expected);
}
/**
* Ist der gespeicherte Hash mit schwaecheren Parametern erzeugt worden als
* heute ueblich?
*
* Der Aufwand laesst sich nur fuer neue Passwoerter anheben – bestehende
* Hashes bleiben sonst fuer immer auf dem alten Stand. OWASP empfiehlt
* deshalb, bei der naechsten erfolgreichen Anmeldung still neu zu hashen.
* Das geht nur dort, weil das Klartextpasswort ausschliesslich in diesem
* Moment vorliegt.
*/
export function needsRehash(stored: string | null | undefined): boolean {
if (!stored) return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
const [N, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return true;
// Verglichen wird der Gesamtaufwand, nicht Feld fuer Feld: N=2^16 mit p=2
// ist nach OWASP genauso zulaessig wie N=2^17 mit p=1.
return N * r * p < PARAMS.N * PARAMS.r * PARAMS.p;
}
/**
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
@@ -84,27 +121,102 @@ export async function fakeVerifyDelay(): Promise<void> {
});
}
/**
* Naheliegende Passwoerter.
*
* ASVS 2.1.7 verlangt einen Abgleich gegen bekannte geleakte Passwoerter.
* Ein Dienst wie Have-I-Been-Pwned schiede dafuer aus – Nebula ruft
* grundsaetzlich nichts von aussen, und ein Passwort-Praefix nach draussen
* zu geben widerspraeche dem erst recht. Diese Liste ist der Kompromiss:
* die haeufigsten deutschen und englischen Kandidaten, dazu weiter unten
* Muster statt Einzelwoerter, was mehr abdeckt als jede Liste dieser Groesse.
*/
const OBVIOUS = new Set([
"password", "passwort", "12345678", "123456789", "1234567890",
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
"willkommen", "nebula123", "geheim123",
// Klassiker
"password", "password1", "password123", "passwort", "passwort1",
"passwort123", "kennwort", "geheim", "geheim123", "changeme", "letmein",
"welcome", "willkommen", "willkommen1", "administrator", "admin1234",
"iloveyou", "sunshine", "princess", "dragon", "monkey", "football",
"baseball", "superman", "trustno1", "starwars", "whatever", "freedom",
// Zahlen und Tastaturlaeufe
"12345678", "123456789", "1234567890", "87654321", "11111111",
"qwertzuiop", "qwertyuiop", "asdfghjkl", "qwertz123", "qwerty123",
"1qay2wsx", "1q2w3e4r", "1q2w3e4r5t", "asdf1234", "qwer1234",
// Deutsch
"sommer2024", "sommer2025", "sommer2026", "winter2024", "winter2025",
"winter2026", "fussball", "deutschland", "hallowelt", "sonnenschein",
"schatz123", "liebling", "blumen123", "internet", "computer",
// Projektnah
"nebula", "nebula123", "nebula2026", "dashboard", "vibecoding",
]);
export { PASSWORD_MIN_LENGTH };
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
export function validatePasswordStrength(password: string): string | null {
/**
* Laenge der laengsten auf- oder absteigenden Zeichenfolge ("abcd", "4321").
*
* Zurueckgegeben wird die Laenge und nicht nur ein Ja/Nein: entscheidend ist,
* *wie viel* des Passworts aus der Folge besteht. Ein "de" mitten in einem
* Wort ist harmlos, "abcdefghijkl" ist das ganze Passwort.
*/
function laengsteFolge(wert: string): number {
if (wert.length < 2) return wert.length;
let beste = 1;
let auf = 1;
let ab = 1;
for (let i = 1; i < wert.length; i += 1) {
const schritt = wert.charCodeAt(i) - wert.charCodeAt(i - 1);
auf = schritt === 1 ? auf + 1 : 1;
ab = schritt === -1 ? ab + 1 : 1;
beste = Math.max(beste, auf, ab);
}
return beste;
}
/**
* Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt.
*
* Bewusst **keine** Zusammensetzungsregeln ("mindestens ein Sonderzeichen").
* ASVS 2.1.9 raet ausdruecklich davon ab: sie treiben Leute zu "Passwort1!"
* und senken die Entropie, statt sie zu heben. Geprueft wird stattdessen
* auf Laenge und auf das, was nachweislich schwach ist.
*/
export function validatePasswordStrength(
password: string,
/** Benutzername des Kontos, falls bekannt – er darf nicht im Passwort stehen. */
username?: string | null,
): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
}
if (password.length > 200) {
return "Das Passwort darf hoechstens 200 Zeichen haben.";
}
if (OBVIOUS.has(password.toLowerCase())) {
const klein = password.toLowerCase();
if (OBVIOUS.has(klein)) {
return "Dieses Passwort ist zu naheliegend.";
}
if (/^\d+$/.test(password)) {
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
}
// Zu wenig verschiedene Zeichen – deckt "aaaaaaaa" ebenso ab wie
// "abababab" oder "12121212", ohne dafuer je ein eigenes Muster zu
// brauchen.
if (new Set(klein).size <= 4) {
return "Das Passwort benutzt zu wenige verschiedene Zeichen.";
}
// Und wenn der groesste Teil des Passworts eine durchlaufende Folge ist.
if (laengsteFolge(klein) >= Math.max(6, Math.ceil(password.length * 0.6))) {
return "Zeichenfolgen wie „abcd“ oder „4321“ sind zu leicht zu raten.";
}
// Ein Passwort, das den Benutzernamen enthaelt, ist der erste Versuch
// jedes Angreifers.
const name = username?.trim().toLowerCase();
if (name && name.length >= 3 && klein.includes(name)) {
return "Das Passwort darf den Benutzernamen nicht enthalten.";
}
return null;
}
+33 -6
View File
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
import { cookies } from "next/headers";
import type { NextResponse } from "next/server";
import type { User } from "@prisma/client";
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
import {
COOKIES_SECURE,
SESSION_COOKIE,
SESSION_IDLE_MS,
SESSION_TTL_MS,
} from "@/lib/config";
import { prisma } from "@/lib/db";
/**
@@ -68,7 +73,10 @@ export function setSessionCookie(response: NextResponse, token: string, expiresA
name: SESSION_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE,
path: "/",
expires: expiresAt,
@@ -80,7 +88,10 @@ export function clearSessionCookie(response: NextResponse) {
name: SESSION_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
sameSite: "strict",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
@@ -109,11 +120,27 @@ export async function getSessionUser(): Promise<SessionUser | null> {
return null;
}
// Leerlaufgrenze. Vorher wurde lastSeenAt zwar gefuehrt, aber nie
// ausgewertet – ein abgegriffener Cookie galt damit bis zum absoluten
// Ablauf, auch wenn das Konto wochenlang unberuehrt war.
const untaetig = Date.now() - session.lastSeenAt.getTime();
if (SESSION_IDLE_MS > 0 && untaetig > SESSION_IDLE_MS) {
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
return null;
}
if (!session.user.isActive) return null;
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
// Datenbank anfasst.
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
/**
* lastSeenAt nachfuehren, aber nicht bei jeder Anfrage schreiben.
*
* Das Intervall muss deutlich unter der Leerlaufgrenze liegen, sonst
* verkuerzt die Schreibbremse sie: bei stuendlichem Schreiben und einer
* Grenze von einer Stunde waere die Sitzung schon nach Sekunden faellig.
* Ein Zwanzigstel der Frist, hoechstens fuenf Minuten.
*/
const intervall = SESSION_IDLE_MS > 0 ? Math.min(SESSION_IDLE_MS / 20, 5 * 60 * 1000) : 60 * 60 * 1000;
if (untaetig > intervall) {
void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
+60
View File
@@ -41,6 +41,66 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
};
export const CHANGELOG: Release[] = [
{
version: "1.9.0",
date: "2026-08-17",
headline: "Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab.",
changes: [
{
kind: "neu",
text:
"Der Abgleich prüft jetzt den Zustand verknüpfter Issues: was in Git geschlossen " +
"wurde, wird in Nebula abgehakt. Bisher konnte das nur der Webhook — war keiner " +
"eingerichtet, blieb der Rückweg ganz aus.",
},
{
kind: "besser",
text:
"Ein über Git geschlossenes Issue bewirkt jetzt dasselbe wie ein Haken in der " +
"Oberfläche: bei wiederkehrenden Aufgaben entsteht die nächste Fassung, und der " +
"Vorgang steht im Projektverlauf.",
},
],
},
{
version: "1.8.0",
date: "2026-08-15",
headline: "Durchgang durch Anmeldung und Sitzungen nach OWASP.",
changes: [
{
kind: "besser",
text:
"Passwörter werden deutlich aufwendiger gehasht (scrypt mit N=2^17 statt 2^15) — " +
"das entspricht der Empfehlung des OWASP Password Storage Cheat Sheet. Bestehende " +
"Passwörter werden bei der nächsten Anmeldung still auf den neuen Stand gebracht.",
},
{
kind: "behoben",
text:
"Die Anmeldung verriet, welche Benutzernamen existieren: gesperrte, deaktivierte " +
"und reine Passkey-Konten antworteten anders als unbekannte — und das schon vor " +
"der Passwortprüfung. Jetzt gibt es die Auskunft erst, wenn das Passwort stimmt.",
},
{
kind: "neu",
text:
"Sitzungen laufen nach acht Stunden ohne Nutzung ab, zusätzlich zur bestehenden " +
"Frist von 30 Tagen. Einstellbar über SESSION_IDLE_HOURS; 0 schaltet es ab.",
},
{
kind: "besser",
text:
"Das Sitzungscookie trägt bei HTTPS den Präfix „__Host-“ und gilt nur noch " +
"SameSite=Strict. Beides muss einmalig neu angemeldet werden.",
},
{
kind: "besser",
text:
"Die Passwortprüfung erkennt jetzt Wiederholungen, Zeichenfolgen und Passwörter, " +
"die den eigenen Benutzernamen enthalten.",
},
],
},
{
version: "1.7.0",
date: "2026-08-15",
+32 -3
View File
@@ -73,18 +73,47 @@ export const WEBAUTHN_AVAILABLE =
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
/** Lebensdauer einer Sitzung. */
/**
* Absolute Lebensdauer einer Sitzung – ab Anmeldung, nicht verlaengerbar.
* Nach dieser Frist ist Schluss, egal wie aktiv jemand war.
*/
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
export const SESSION_COOKIE = "nebula_session";
/**
* Leerlauffrist: so lange darf eine Sitzung unbenutzt bleiben.
*
* ASVS 3.3.2 verlangt beides – eine absolute *und* eine Leerlaufgrenze.
* Vorher gab es nur die absolute, ein abgegriffener Cookie galt also bis zu
* 30 Tage, unabhaengig davon, ob das Konto benutzt wurde.
*
* Acht Stunden sind der Kompromiss: ein Arbeitstag am Stueck bleibt
* angemeldet, ueber Nacht laeuft die Sitzung ab. Wem das zu streng ist,
* setzt SESSION_IDLE_HOURS hoch; 0 schaltet die Leerlaufgrenze ab.
*/
export const SESSION_IDLE_HOURS = Number(process.env.SESSION_IDLE_HOURS ?? 8);
export const SESSION_IDLE_MS =
SESSION_IDLE_HOURS > 0 ? SESSION_IDLE_HOURS * 60 * 60 * 1000 : 0;
/**
* Name des Sitzungscookies.
*
* Mit dem Praefix "__Host-" erzwingt der Browser selbst, was wir ohnehin
* setzen: Secure, Pfad "/" und kein Domain-Attribut. Letzteres ist der
* eigentliche Gewinn – eine andere Subdomain kann den Cookie dann nicht
* ueberschreiben und damit keine Sitzung unterschieben.
*
* Nur bei https, denn der Praefix verlangt zwingend Secure; ohne TLS wuerde
* der Browser den Cookie schlicht verwerfen.
*/
export const SESSION_COOKIE = COOKIES_SECURE ? "__Host-nebula_session" : "nebula_session";
/**
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
* vorne an.
*/
export const MFA_COOKIE = "nebula_mfa";
export const MFA_COOKIE = COOKIES_SECURE ? "__Host-nebula_mfa" : "nebula_mfa";
export const MFA_TTL_MS = 5 * 60 * 1000;
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
+9
View File
@@ -71,6 +71,15 @@ export class GiteaClient implements GitClient {
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> {
try {
const version = await getJsonSafe<{ version?: string }>(
+9
View File
@@ -70,6 +70,15 @@ export class GitHubClient implements GitClient {
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
+10
View File
@@ -70,6 +70,16 @@ export class GitLabClient implements GitClient {
);
}
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
// GitLab nennt den offenen Zustand "opened".
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> {
try {
if (!this.token) {
+11
View File
@@ -190,6 +190,17 @@ export interface GitClient {
* geoeffnet – daher der Schalter statt zweier Methoden.
*/
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
/**
* Liest den Zustand eines einzelnen Issues.
*
* Gebraucht fuer den Abgleich: der Snapshot fuehrt nur die *offenen*
* Issues und ist ausserdem gedeckelt – aus dem Fehlen einer Nummer laesst
* sich deshalb nicht schliessen, dass sie geschlossen wurde. Sie koennte
* ebenso geloescht oder einfach jenseits des Deckels sein.
*
* null heisst: gibt es nicht (mehr).
*/
getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null>;
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
berlinMitternacht,
tagesschluessel,
wocheUm,
wochenName,
wochenTitel,
zaehleWoche,
} from "./review";
describe("tagesschluessel", () => {
it("rechnet in Berliner Kalendertagen, nicht in UTC", () => {
// 22:30 UTC ist in Berlin schon der Folgetag (Sommerzeit, UTC+2).
expect(tagesschluessel(new Date("2026-08-17T22:30:00Z"))).toBe("2026-08-18");
expect(tagesschluessel(new Date("2026-08-17T21:00:00Z"))).toBe("2026-08-17");
});
});
describe("berlinMitternacht", () => {
it("trifft den Sommerzeit-Beginn des Tages", () => {
// Sommerzeit: Berlin = UTC+2, Mitternacht dort ist 22:00 UTC am Vortag.
expect(berlinMitternacht("2026-08-17").toISOString()).toBe("2026-08-16T22:00:00.000Z");
});
it("trifft den Winterzeit-Beginn des Tages", () => {
// Winterzeit: UTC+1.
expect(berlinMitternacht("2026-01-12").toISOString()).toBe("2026-01-11T23:00:00.000Z");
});
});
describe("wocheUm", () => {
/** Ein Dienstag. */
const DIENSTAG = new Date("2026-08-18T09:00:00Z");
it("beginnt am Montag und endet am folgenden Montag", () => {
const woche = wocheUm(DIENSTAG);
expect(woche.tage[0]).toBe("2026-08-17");
expect(woche.tage[6]).toBe("2026-08-23");
expect(woche.tage).toHaveLength(7);
expect(woche.von.toISOString()).toBe("2026-08-16T22:00:00.000Z");
expect(woche.bis.toISOString()).toBe("2026-08-23T22:00:00.000Z");
});
it("zaehlt den Montag selbst zur laufenden Woche", () => {
const montag = new Date("2026-08-17T06:00:00Z");
expect(wocheUm(montag).tage[0]).toBe("2026-08-17");
});
it("zaehlt den Sonntagabend noch zur ablaufenden Woche", () => {
// 21:00 UTC am Sonntag ist in Berlin 23:00 – noch derselbe Tag.
const sonntagAbend = new Date("2026-08-23T21:00:00Z");
expect(wocheUm(sonntagAbend).tage[0]).toBe("2026-08-17");
// Zwei Stunden spaeter ist Montag und damit die naechste Woche.
const montagFrueh = new Date("2026-08-23T22:30:00Z");
expect(wocheUm(montagFrueh).tage[0]).toBe("2026-08-24");
});
it("blättert rückwärts", () => {
expect(wocheUm(DIENSTAG, -1).tage[0]).toBe("2026-08-10");
expect(wocheUm(DIENSTAG, -3).tage[0]).toBe("2026-07-27");
});
it("kommt über den Monats- und Jahreswechsel", () => {
const neujahr = new Date("2027-01-01T12:00:00Z"); // ein Freitag
const woche = wocheUm(neujahr);
expect(woche.tage[0]).toBe("2026-12-28");
expect(woche.tage[6]).toBe("2027-01-03");
});
it("bleibt über die Zeitumstellung hinweg sieben Tage lang", () => {
// Am 25.10.2026 wird auf Winterzeit zurueckgestellt – die Woche hat
// dann 169 Stunden. "von + 7 Tage" waere hier eine Stunde daneben.
const inDerWoche = new Date("2026-10-21T12:00:00Z");
const woche = wocheUm(inDerWoche);
expect(woche.tage[0]).toBe("2026-10-19");
expect(woche.tage[6]).toBe("2026-10-25");
const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
expect(stunden).toBe(169);
});
it("liefert bei der Umstellung im Fruehjahr 167 Stunden", () => {
// Vorgestellt wird am letzten Sonntag im Maerz – 2026 ist das der 29.,
// er liegt also in der Woche ab Montag, dem 23.
const inDerWoche = new Date("2026-03-25T12:00:00Z");
const woche = wocheUm(inDerWoche);
expect(woche.tage[0]).toBe("2026-03-23");
expect(woche.tage[6]).toBe("2026-03-29");
const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
expect(stunden).toBe(167);
});
it("hat in einer Woche ohne Umstellung genau 168 Stunden", () => {
const woche = wocheUm(new Date("2026-03-31T12:00:00Z"));
expect(woche.tage[0]).toBe("2026-03-30");
expect((woche.bis.getTime() - woche.von.getTime()) / 3_600_000).toBe(168);
});
});
describe("Beschriftung", () => {
const DIENSTAG = new Date("2026-08-18T09:00:00Z");
it("nennt die laufende und die vorige Woche beim Namen", () => {
expect(wochenName(wocheUm(DIENSTAG))).toBe("Diese Woche");
expect(wochenName(wocheUm(DIENSTAG, -1))).toBe("Vorige Woche");
});
it("nennt weiter zurueckliegende Wochen mit Zeitraum", () => {
expect(wochenName(wocheUm(DIENSTAG, -2))).toMatch(/\d/);
});
it("nennt den Monat nur einmal, wenn die Woche nicht darueber hinausgeht", () => {
expect(wochenTitel(wocheUm(DIENSTAG))).toBe("17.–23. August 2026");
});
it("nennt beide Monate, wenn die Woche sie ueberspannt", () => {
// 31.08. bis 06.09.2026.
const woche = wocheUm(new Date("2026-09-02T12:00:00Z"));
expect(wochenTitel(woche)).toBe("31. August–6. September 2026");
});
});
describe("zaehleWoche", () => {
const eintrag = (
kind: Parameters<typeof zaehleWoche>[0][number]["kind"],
projectId: string,
meta?: unknown,
) => ({ kind, projectId, meta });
it("zaehlt Erledigtes am meta-Feld, nicht am Text", () => {
const zahlen = zaehleWoche([
eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DONE" }),
// Verschoben, aber nicht erledigt – darf nicht mitzaehlen.
eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DOING" }),
]);
expect(zahlen.erledigt).toBe(1);
});
it("zaehlt einen Eintrag ohne meta nicht als erledigt", () => {
// Aeltere Eintraege koennen kein "to" haben. Lieber untertreiben als
// eine Zahl behaupten, die aus dem Anzeigetext geraten waere.
expect(zaehleWoche([eintrag("TASK_MOVED", "p1")]).erledigt).toBe(0);
expect(zaehleWoche([eintrag("TASK_MOVED", "p1", "unsinn")]).erledigt).toBe(0);
});
it("zaehlt neue Aufgaben aus beiden Quellen", () => {
const zahlen = zaehleWoche([
eintrag("TASK_ADDED", "p1"),
eintrag("TASKS_IMPORTED", "p2"),
]);
expect(zahlen.neueAufgaben).toBe(2);
});
it("zaehlt beruehrte Projekte ohne Dubletten", () => {
const zahlen = zaehleWoche([
eintrag("NOTE_ADDED", "p1"),
eintrag("NOTE_ADDED", "p1"),
eintrag("REPO_COMMITS", "p2"),
]);
expect(zahlen.projekte).toBe(2);
expect(zahlen.notizen).toBe(2);
expect(zahlen.gesamt).toBe(3);
});
it("kommt mit einer leeren Woche zurecht", () => {
expect(zaehleWoche([])).toEqual({
gesamt: 0,
projekte: 0,
erledigt: 0,
neueAufgaben: 0,
notizen: 0,
});
});
});
+203
View File
@@ -0,0 +1,203 @@
import type { ActivityKind } from "@prisma/client";
/**
* Wochenrueckblick: welche Woche, und was zaehlt darin.
*
* Die Wochenrechnung steht hier als reine Funktion und nicht in der Seite –
* Wochengrenzen sind der Teil, der still falsch wird, und so laesst er sich
* pruefen.
*/
/** Wochenbeginn ist Montag, wie in Deutschland ueblich (ISO 8601). */
export const WOCHENBEGINN = 1;
/**
* Kalendertag in fester Zeitzone.
*
* Dieselbe Ueberlegung wie bei den Faelligkeiten in lib/tasks: im Container
* laeuft die Anwendung nach UTC. Um 23:30 Berliner Zeit waere dort noch der
* Vortag, und ein Eintrag von Sonntagabend fiele in die falsche Woche.
*/
const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/** "2026-08-17" in Berliner Zeit. */
export function tagesschluessel(wert: Date): string {
return TAG_FORMAT.format(wert);
}
/** Wochentag in Berliner Zeit, 0 = Sonntag. */
function wochentag(wert: Date): number {
const [jahr, monat, tag] = tagesschluessel(wert).split("-").map(Number);
return new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay();
}
export interface Woche {
/** Beginn des Montags, als Zeitpunkt fuer die Abfrage. */
von: Date;
/** Beginn des folgenden Montags – ausschliesslich. */
bis: Date;
/** 0 = laufende Woche, -1 = vorige, … */
versatz: number;
/** Die sieben Kalendertage der Woche, Montag zuerst. */
tage: string[];
}
/**
* Grenzen der Woche, in der `jetzt` liegt – um `versatz` Wochen verschoben.
*
* Gerechnet wird ueber die Kalendertage in Berliner Zeit und dann in UTC
* zurueckgerechnet. Der Umweg ist noetig, weil zwischen zwei Montagen eine
* Zeitumstellung liegen kann: "sieben mal 24 Stunden" waere dann um eine
* Stunde daneben.
*/
export function wocheUm(jetzt: Date, versatz = 0): Woche {
const [jahr, monat, tag] = tagesschluessel(jetzt).split("-").map(Number);
const heuteUtc = Date.UTC(jahr, monat - 1, tag);
// Rueckwaerts bis zum Montag.
const abstandZumMontag = (wochentag(jetzt) - WOCHENBEGINN + 7) % 7;
const montag = new Date(heuteUtc - (abstandZumMontag - versatz * 7) * 86_400_000);
const tage: string[] = [];
for (let i = 0; i < 7; i += 1) {
tage.push(new Date(montag.getTime() + i * 86_400_000).toISOString().slice(0, 10));
}
const von = berlinMitternacht(tage[0]);
return {
von,
// Ende ist die Mitternacht *nach* dem Sonntag – nicht "von + 7 Tage":
// liegt eine Zeitumstellung dazwischen, ist die Woche 167 oder 169
// Stunden lang.
bis: berlinMitternacht(naechsterTag(tage[6])),
versatz,
tage,
};
}
/** Ein Kalendertag weiter, rein auf der Zeichenkette gerechnet. */
function naechsterTag(tag: string): string {
const [j, m, t] = tag.split("-").map(Number);
return new Date(Date.UTC(j, m - 1, t) + 86_400_000).toISOString().slice(0, 10);
}
/**
* Um wie viel geht die Berliner Uhr zu diesem Zeitpunkt der UTC voraus?
*
* Ermittelt statt fest verdrahtet, weil zwischen Sommer- und Winterzeit eine
* Stunde Unterschied liegt und ein Rueckblick ueber die Umstellung hinweg
* sonst um eine Stunde verrutscht.
*/
function zonenversatzMs(zeitpunkt: Date): number {
// sv-SE liefert "2026-08-17 14:30:00" – nah genug an ISO, um es nach dem
// Austausch des Leerzeichens wieder einlesen zu koennen.
const berlin = new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
dateStyle: "short",
timeStyle: "medium",
}).format(zeitpunkt);
return Date.parse(`${berlin.replace(" ", "T")}Z`) - zeitpunkt.getTime();
}
/** Der Zeitpunkt, an dem in Berlin dieser Kalendertag beginnt. */
export function berlinMitternacht(tag: string): Date {
const basis = new Date(`${tag}T00:00:00Z`);
// Die Umstellung findet nachts um zwei statt, nie um Mitternacht – der
// Versatz an dieser Stelle ist also eindeutig.
return new Date(basis.getTime() - zonenversatzMs(basis));
}
export interface Kennzahlen {
gesamt: number;
projekte: number;
erledigt: number;
neueAufgaben: number;
notizen: number;
}
/** Was ein Verlaufseintrag fuer die Zaehlung hergeben muss. */
export interface ZaehlbarerEintrag {
kind: ActivityKind;
projectId: string;
meta?: unknown;
}
/**
* Kennzahlen einer Woche.
*
* "Erledigt" kommt aus dem `meta`-Feld (`to: "DONE"`) und nicht aus dem
* Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
* Formulierung – eine Zaehlung, die daran haengt, wird beim naechsten
* umformulierten Einzeiler still falsch.
*
* Fuer aeltere Eintraege ohne `to` bleibt ein Rueckfall auf den Text: der
* Verlauf haelt bis zu 200 Eintraege je Projekt, darunter also durchaus
* welche von vor dieser Aenderung.
*/
export function zaehleWoche(eintraege: ZaehlbarerEintrag[]): Kennzahlen {
let erledigt = 0;
let neueAufgaben = 0;
let notizen = 0;
const projekte = new Set<string>();
for (const eintrag of eintraege) {
projekte.add(eintrag.projectId);
if (eintrag.kind === "TASK_ADDED" || eintrag.kind === "TASKS_IMPORTED") {
neueAufgaben += 1;
} else if (eintrag.kind === "NOTE_ADDED") {
notizen += 1;
} else if (eintrag.kind === "TASK_MOVED" && istErledigt(eintrag)) {
erledigt += 1;
}
}
return { gesamt: eintraege.length, projekte: projekte.size, erledigt, neueAufgaben, notizen };
}
function istErledigt(eintrag: ZaehlbarerEintrag): boolean {
const meta = eintrag.meta;
if (meta && typeof meta === "object" && "to" in meta) {
return (meta as { to?: unknown }).to === "DONE";
}
return false;
}
const MONAT_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
month: "long",
});
/**
* Beschriftung einer Woche: "17.–23. August 2026", ueber einen
* Monatswechsel hinweg "31. August–6. September 2026".
*
* Der Punkt hinter der Tageszahl kommt von Hand: Intl liefert bei
* `day: "numeric"` nur die Ziffer, und mit `day: "2-digit"` staende dort
* "07." statt "7.".
*/
export function wochenTitel(woche: Woche): string {
const teile = (tag: string) => {
const [j, m, t] = tag.split("-").map(Number);
return { tag: t, monat: MONAT_FORMAT.format(new Date(Date.UTC(j, m - 1, t, 12))), jahr: j };
};
const von = teile(woche.tage[0]);
const bis = teile(woche.tage[6]);
const gleicherMonat = von.monat === bis.monat && von.jahr === bis.jahr;
const links = gleicherMonat ? `${von.tag}.` : `${von.tag}. ${von.monat}`;
return `${links}–${bis.tag}. ${bis.monat} ${bis.jahr}`;
}
/** "Diese Woche", "Vorige Woche", sonst der Zeitraum. */
export function wochenName(woche: Woche): string {
if (woche.versatz === 0) return "Diese Woche";
if (woche.versatz === -1) return "Vorige Woche";
return wochenTitel(woche);
}
+22 -1
View File
@@ -5,6 +5,7 @@ import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories";
import { reconcileLinkedIssues } from "@/lib/tasks/issues";
import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult {
@@ -62,11 +63,31 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
// README hineingeschrieben werden koennen.
await maybeRefreshCalendar(repository.id, client, ref);
await maybeRefreshReadme(repository.id, client, ref);
/**
* Verknuepfte Aufgaben nachziehen: was in Git geschlossen wurde, wird
* hier abgehakt. Der Webhook macht das sofort – ist keiner eingerichtet
* oder faellt er aus, greift der Abgleich.
*
* Die offenen Issues aus dem gerade geholten Snapshot gehen mit hinein:
* fuer jede Nummer, die dort steht, ist die Nachfrage gespart.
*/
const abgehakt = await reconcileLinkedIssues(
repository.id,
client,
ref,
data.issues.map((issue) => issue.number),
);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id);
return {
ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
message:
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
(abgehakt > 0
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
: ""),
fetchedAt: new Date().toISOString(),
};
} catch (error) {
+130
View File
@@ -1,6 +1,9 @@
import type { TaskStatus } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git";
import type { GitClient, RepoRef } from "@/lib/git/types";
import { nextPosition, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
/**
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
@@ -68,3 +71,130 @@ export async function mirrorStatusToIssue(
return false;
}
}
/**
* Hakt eine Aufgabe ab, weil ihr Issue geschlossen wurde.
*
* Bewusst an einer Stelle fuer beide Wege – Webhook und Abgleich. Vorher
* setzte der Webhook nur den Status und liess dabei aus, was die Oberflaeche
* tut: die naechste Fassung einer wiederkehrenden Aufgabe anlegen und den
* Vorgang in den Verlauf schreiben.
*
* Gibt false zurueck, wenn nichts zu tun war (Aufgabe fehlt oder ist schon
* erledigt) – der Aufrufer kann daran zaehlen, was er bewirkt hat.
*/
export async function completeTaskFromIssue(
taskId: string,
herkunft: "webhook" | "abgleich",
): Promise<boolean> {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { id: true, title: true, status: true, projectId: true, sourceIssue: true },
});
if (!task || task.status === "DONE") return false;
await prisma.task.update({
where: { id: task.id },
data: {
status: "DONE",
doneAt: new Date(),
position: await nextPosition(task.projectId, "DONE"),
},
});
// Wie beim Abhaken von Hand: eine wiederkehrende Aufgabe erzeugt ihre
// naechste Fassung.
const folge = await spawnRecurrence(task.id);
await refreshDerivedProgress(task.projectId);
await recordActivity({
projectId: task.projectId,
// Kein userId: das hat nicht ein Mensch in Nebula getan, sondern das
// Git-System gemeldet.
kind: "TASK_MOVED",
summary:
`Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` +
(herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") +
(folge ? ", nächste angelegt" : ""),
// "from"/"to" wie beim Verschieben von Hand – der Wochenrueckblick
// zaehlt daran ab, was erledigt wurde, statt den Text zu durchsuchen.
meta: {
taskId: task.id,
issue: task.sourceIssue,
source: herkunft,
from: task.status,
to: "DONE",
},
});
return true;
}
/** Wie viele Issues ein Abgleich hoechstens einzeln nachfragt. */
const MAX_NACHFRAGEN = 25;
/**
* Gleicht beim Abgleich die verknuepften Issues ab: was in Git geschlossen
* ist, wird in Nebula abgehakt.
*
* Warum zusaetzlich zum Webhook: der Webhook wirkt sofort, setzt aber voraus,
* dass die Instanz von aussen erreichbar ist und der Haken eingetragen wurde.
* Ist er das nicht – oder faellt er einmal aus –, bliebe der Rueckweg sonst
* ganz aus. Hier kommt er verzoegert, aber verlaesslich.
*
* **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
* Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
* Nebula aus fehlgeschlagen ist – dann wuerde ein symmetrischer Abgleich eine
* bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
* Webhook vorbehalten, der ein echtes "reopened" sieht.
*
* Fehler werden geschluckt: ein nicht erreichbares Issue darf den Abgleich
* des Repositories nicht scheitern lassen.
*/
export async function reconcileLinkedIssues(
repositoryId: string,
client: GitClient,
ref: RepoRef,
/** Nummern, die der Snapshot gerade als offen gemeldet hat. */
offeneNummern: number[] = [],
): Promise<number> {
const offen = new Set(offeneNummern);
const kandidaten = await prisma.task.findMany({
where: {
sourceRepositoryId: repositoryId,
sourceIssue: { not: null },
status: { not: "DONE" },
},
select: { id: true, sourceIssue: true },
orderBy: { createdAt: "asc" },
});
let abgehakt = 0;
let nachfragen = 0;
for (const task of kandidaten) {
const nummer = task.sourceIssue;
if (nummer === null) continue;
// Steht die Nummer in der Liste der offenen Issues, ist nichts zu tun –
// und der Aufruf gespart. Umgekehrt gilt das nicht: die Liste ist
// gedeckelt, aus dem Fehlen folgt also nichts.
if (offen.has(nummer)) continue;
if (nachfragen >= MAX_NACHFRAGEN) break;
nachfragen += 1;
try {
const zustand = await client.getIssueState(ref, nummer);
// null heisst "gibt es nicht mehr" – geloescht ist nicht erledigt,
// also bleibt die Aufgabe stehen.
if (zustand !== "closed") continue;
if (await completeTaskFromIssue(task.id, "abgleich")) abgehakt += 1;
} catch {
// Nichts erreichbar, nichts geaendert – der naechste Lauf versucht es.
}
}
return abgehakt;
}
+12 -2
View File
@@ -15,7 +15,17 @@ import { NextResponse, type NextRequest } from "next/server";
*/
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/**
* Beide moeglichen Namen des Sitzungscookies.
*
* Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix
* verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand
* der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es
* geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet
* requireUserPage bzw. requireUserApi.
*/
const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"];
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
@@ -110,7 +120,7 @@ export function middleware(request: NextRequest) {
}
// ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value));
if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
+18
View File
@@ -100,6 +100,24 @@ export interface TaskOverviewItem {
project: { id: string; name: string; accent: string; status: ProjectStatus };
}
/** Ein Verlaufseintrag im Wochenrueckblick – mit Projekt, ueber Projekte hinweg. */
export interface ReviewEntry {
id: string;
kind: ActivityKind;
summary: string;
/** ISO – ein Date ueberlebt den Weg zum Browser nicht. */
createdAt: string;
project: { id: string; name: string; accent: string };
user: { username: string; displayName: string | null } | null;
}
/** Ein Kalendertag der Woche samt seiner Eintraege. */
export interface ReviewDay {
/** "2026-08-17", in Berliner Zeit bestimmt. */
tag: string;
eintraege: ReviewEntry[];
}
export interface RepoListEntry {
fullPath: string;
owner: string;