Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09ef12920c | ||
|
|
6b14981766 | ||
|
|
f9a616e596 | ||
|
|
7919149879 | ||
|
|
2e57713a8e | ||
|
|
10b49cf8df | ||
|
|
9565b9a920 | ||
|
|
b17c42af56 | ||
|
|
6d3ebd05ae | ||
|
|
d5c7974af3 | ||
|
|
bbac268c59 |
@@ -6,6 +6,35 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.7.0] – 2026-09-13
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Schnellerfassung vom Handy** unter _Erfassen_: ein Feld, ein Knopf. Was
|
||||
ohne Netz erfasst wird, bleibt im Gerät und geht hinaus, sobald wieder eine
|
||||
Verbindung da ist — mit einer Kennung je Erfassung, damit eine doppelt
|
||||
zugestellte Nachricht keine zweite Aufgabe ergibt.
|
||||
- Der Planer lässt sich als **App auf dem Startbildschirm ablegen**
|
||||
(Manifest, eigenes Symbol, eigener Startpunkt).
|
||||
- **Öffentliche Schnittstelle** unter `/api/v1`: Aufgaben lesen, anlegen,
|
||||
ändern und abhaken. Beschreibung in [docs/API.md](docs/API.md).
|
||||
- **Tokens je Anwendung** mit Rechten je Token, wahlweise mit Ablauf,
|
||||
verwaltbar unter _API_.
|
||||
- Handbuchseite zur Schnellerfassung.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Von einem Token liegt nur der Fingerabdruck in der Datenbank. Der Wert
|
||||
erscheint genau einmal — beim Anlegen.
|
||||
- Die drei Rechte schließen einander nicht ein: Ein Dienst, der nur erfasst,
|
||||
kann den Bestand nicht lesen.
|
||||
- Jeder Fehlschlag beim Ausweisen sieht gleich aus. Ob ein Token unbekannt,
|
||||
abgelaufen oder zurückgezogen ist, verrät die Schnittstelle nicht.
|
||||
- Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token — auch jeder
|
||||
abgewiesene Zugriff.
|
||||
- Die Schnittstelle kann weder löschen noch Bereiche, Projekte, Zeitblöcke
|
||||
oder Kalender anfassen.
|
||||
|
||||
## [0.6.0] – 2026-09-12
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
@@ -39,6 +39,10 @@ wird.
|
||||
- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
|
||||
hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
|
||||
Titel
|
||||
- **Schnellerfassung vom Handy**, die auch im Funkloch nichts verliert; der
|
||||
Planer lässt sich als App auf dem Startbildschirm ablegen
|
||||
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
||||
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
|
||||
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||
Passwort — je Instanz einzeln zuschaltbar
|
||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||
@@ -49,8 +53,7 @@ wird.
|
||||
|
||||
### Geplant
|
||||
|
||||
Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
|
||||
Siehe [ROADMAP.md](ROADMAP.md).
|
||||
Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
## Bildergalerie
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
|
||||
| [M6](#m6--integrationen) | Integrationen | erledigt |
|
||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | erledigt |
|
||||
| [M8](#m8--mcp) | MCP | geplant |
|
||||
|
||||
---
|
||||
@@ -134,9 +134,25 @@ im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
|
||||
|
||||
## M7 – Schnellerfassung und API
|
||||
|
||||
- [ ] Erfassung vom Handy, auch offline
|
||||
- [ ] Öffentliche API mit Token je Anwendung
|
||||
- [ ] Rechte je Token, jede Nutzung im Protokoll
|
||||
**Erledigt am 12.09.2026 mit Version 0.7.0.**
|
||||
|
||||
**Ziel:** Etwas festhalten können, wo man gerade steht — und andere
|
||||
Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu geben.
|
||||
|
||||
- [x] Erfassung vom Handy, auch offline
|
||||
- [x] Öffentliche API mit Token je Anwendung
|
||||
- [x] Rechte je Token, jede schreibende Nutzung im Protokoll
|
||||
- [x] Als App ablegbar (Manifest, eigenes Symbol)
|
||||
|
||||
**Offen geblieben:** Die Bilder der Erfassungsseite im Handbuch zeigen noch
|
||||
eine falsche Inbox-Zahl; der Fehler selbst ist behoben, nur der Bildlauf
|
||||
kam danach nicht mehr durch.
|
||||
|
||||
**Bewusst offen gelassen:** Die Schnittstelle kann keine Bereiche, Projekte
|
||||
oder Zeitblöcke anfassen und nichts löschen. Was von außen kommt, landet in
|
||||
der Inbox; die Einordnung bleibt bei dem, der plant. Auch eine Begrenzung der
|
||||
Aufrufzahl gibt es nicht — bei einer Handvoll eigener Tokens wäre sie mehr
|
||||
Zeremonie als Schutz.
|
||||
|
||||
## M8 – MCP
|
||||
|
||||
|
||||
@@ -13,7 +13,108 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
||||
| M4 | claude | 167 | 2,78 |
|
||||
| M5 | claude | 420 | 7,00 |
|
||||
| M6 | claude | 733 | 12,22 |
|
||||
| **Gesamt** | | **1566** | **26,10** |
|
||||
| M7 | claude | 886 | 14,77 |
|
||||
| **Gesamt** | | **2452** | **40,87** |
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-13 – M7: Schnellerfassung und API
|
||||
|
||||
**Dauer:** 886 Minuten (10:15–01:01), Art `claude`.
|
||||
|
||||
> Wie schon bei M6 sagt die Zahl mehr über den Rechner als über die Arbeit:
|
||||
> Fünf hängende Docker-Bauten, einer davon mit einem einzelnen Schritt über
|
||||
> drei Stunden. Die Bildaufnahme musste achtmal angestoßen werden.
|
||||
|
||||
**Ziel der Sitzung:** Etwas festhalten können, wo man gerade steht — und
|
||||
andere Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu
|
||||
geben.
|
||||
|
||||
### Erledigt
|
||||
|
||||
- Regeln der Schnittstelle als reine Logik: Rechte, Tokenaufbau, Eingaben
|
||||
(30 Tests)
|
||||
- `/api/v1`: Aufgaben lesen, anlegen, ändern, abhaken
|
||||
- Tokens mit Rechten und wahlweise Ablauf, verwaltbar in der Anwendung
|
||||
- Schnellerfassung fürs Handy mit Warteschlange in IndexedDB
|
||||
- Manifest und Symbol: der Planer lässt sich als App ablegen
|
||||
- 10 neue E2E-Tests, 6 neue Integrationsprüfungen, Handbuchseite, API-Doku
|
||||
|
||||
### Entscheidungen mit Begründung
|
||||
|
||||
**Von einem Token liegt nur der Hash in der Datenbank.** Ein Token ist ein
|
||||
Passwort; es gibt keinen Grund, es zurücklesen zu können, und jeden Grund,
|
||||
es nicht zu können. SHA-256 ohne Salz ist hier richtig — der Wert hat 32
|
||||
Byte Zufall, gegen so etwas hilft kein Wörterbuch, und die Prüfung muss bei
|
||||
jeder Anfrage schnell sein.
|
||||
|
||||
**Kein Recht schließt ein anderes ein.** „Erfassen" darf nicht lesen. Ein
|
||||
Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand nicht zu
|
||||
sehen — und das ist der häufigste Fall.
|
||||
|
||||
**Der sichtbare Anfang heißt `planer_`.** Wer so einen Wert versehentlich in
|
||||
ein öffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen.
|
||||
|
||||
**`/selbst` braucht kein Recht.** Sonst müsste man beim Einrichten raten, ob
|
||||
der Token falsch ist oder nur zu wenig darf.
|
||||
|
||||
**Die Schnittstelle kann Bereich und Projekt nicht setzen.** Was von außen
|
||||
kommt, landet in der Inbox. Die Einordnung ist Sache dessen, der plant,
|
||||
nicht dessen, der meldet. Löschen kann sie auch nicht: Ein Fehler im fremden
|
||||
Skript soll keine Planung vernichten.
|
||||
|
||||
**Erst anstellen, dann senden.** Die Schnellerfassung schreibt zuerst in die
|
||||
Warteschlange des Geräts und versucht dann zu senden. Andersherum wäre der
|
||||
Eintrag weg, wenn der Browser mitten im Senden geschlossen wird.
|
||||
|
||||
**IndexedDB statt localStorage.** Das sind echte Daten des Benutzers. Ein
|
||||
Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
|
||||
Aufgabe — genau das, was der Planer verhindern soll.
|
||||
|
||||
**Der Dienstarbeiter hält genau eine Seite vor.** Ein halb veralteter Planer
|
||||
wäre schlimmer als gar keiner; ein leeres Eingabefeld dagegen veraltet
|
||||
nicht. Und ohne die Seite nützte die Warteschlange niemandem: Man käme gar
|
||||
nicht erst hin.
|
||||
|
||||
**Die Navigation wurde geteilt statt weiter gedehnt.** Nach M6 hatte ich die
|
||||
Leiste verbreitert; mit dem dreizehnten Eintrag verschluckte sie erneut
|
||||
Punkte. Das war die falsche Antwort: Das Problem ist nicht die Breite,
|
||||
sondern die Vermischung. Alltag steht in der Leiste, Einrichtung und
|
||||
Verwaltung liegen hinter „Mehr".
|
||||
|
||||
### Probleme und Lösungen
|
||||
|
||||
| Problem | Lösung |
|
||||
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Die Erfassungsseite zählte die Inbox anders als die Kopfleiste | Sie zählte Ideen mit. Jetzt benutzt sie dieselbe Abfrage. Derselbe Fehlertyp wie in M2 — und wieder nur im Bild aufgefallen, im Code sah jede Seite für sich richtig aus. |
|
||||
| Die Leiste verschluckte mit dem dreizehnten Eintrag wieder Punkte | Aufgeteilt statt verbreitert, siehe oben. Zwei E2E-Tests greifen den neuen Weg über „Mehr" auf. |
|
||||
| Ein E2E-Test brach mit `ERR_ABORTED` ab | Die Erfolgsmeldung kommt, bevor das Neuladen der Liste fertig ist; wer dann navigiert, bricht es ab. Der Test wartet jetzt darauf. |
|
||||
| `exactOptionalPropertyTypes` und Prismas Update-Typen vertragen kein `undefined` | Das Datenobjekt wird Feld für Feld gebaut statt per Spread. Ein gesetztes `undefined` ist etwas anderes als ein fehlendes Feld. |
|
||||
| Ein Devanagari-Zeichen war in ein Token-Präfix der Demo-Daten geraten | Entfernt. Beim Schreiben über die Shell passiert so etwas; die Datei danach durchsucht. |
|
||||
| Fünf hängende Docker-Bauten, einer über drei Stunden | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Zweimal scheiterte zusätzlich das Anlegen des Compose-Netzes. |
|
||||
|
||||
### Offene Punkte
|
||||
|
||||
- **Die Bilder der Erfassungsseite zeigen die alte Inbox-Zahl.** Der Bildlauf
|
||||
ließ sich nach der Korrektur nicht mehr durchbringen. Ein Lauf, wenn Docker
|
||||
wieder mitspielt, räumt das auf.
|
||||
- Keine Begrenzung der Aufrufzahl je Token. Bei einer Handvoll eigener
|
||||
Tokens wäre sie mehr Zeremonie als Schutz — bei einem Token, der außer
|
||||
Haus geht, sähe das anders aus.
|
||||
- Die Warteschlange kennt keinen Abbruch: Ein Eintrag, der dauerhaft
|
||||
scheitert, wird ewig erneut versucht. Bisher gibt es keinen Fall, in dem
|
||||
das passieren könnte, ohne dass gleichzeitig die Anmeldung fehlt.
|
||||
|
||||
### Commits
|
||||
|
||||
- d5c7974 feat: Datenmodell und Regeln für die öffentliche Schnittstelle
|
||||
- 6d3ebd0 feat: API v1 für Aufgaben
|
||||
- b17c42a feat: Tokens verwalten
|
||||
- 9565b9a feat: Schnellerfassung vom Handy, auch ohne Netz
|
||||
- 10b49cf test: Die Tür der Schnittstelle scharf prüfen
|
||||
- 2e57713 docs: Handbuchseite zum Erfassen und Beschreibung der Schnittstelle
|
||||
- 7919149 fix: Navigation aufteilen, statt sie weiter zu dehnen
|
||||
- f9a616e docs: Handbuchbilder für M7 aufnehmen
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ datum;start;ende;minuten;meilenstein;art;notiz
|
||||
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
|
||||
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
||||
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
|
||||
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig
|
||||
|
||||
|
@@ -0,0 +1,158 @@
|
||||
# Öffentliche Schnittstelle
|
||||
|
||||
Für fremde Anwendungen, Skripte und Automaten. Wer den Planer selbst bedient,
|
||||
braucht sie nicht.
|
||||
|
||||
Basisadresse: `<APP_URL>/api/v1`
|
||||
|
||||
## Ausweisen
|
||||
|
||||
Jede Anfrage trägt einen Token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer planer_ab12cd34_…" \
|
||||
https://planer.example.de/api/v1/selbst
|
||||
```
|
||||
|
||||
Alternativ `X-Planer-Token: planer_…`, falls das Werkzeug keine
|
||||
Authorization-Kopfzeile kann.
|
||||
|
||||
Tokens legst du in der Anwendung unter **API** an (nur Verwaltung). Der Wert
|
||||
erscheint **einmal**; danach liegt in der Datenbank nur sein Fingerabdruck.
|
||||
Wer ihn verliert, legt einen neuen an.
|
||||
|
||||
## Rechte
|
||||
|
||||
Drei Stück, und **keines schließt ein anderes ein**:
|
||||
|
||||
| Recht | Was damit geht |
|
||||
| ----------- | -------------------------------------- |
|
||||
| `lesen` | Aufgaben abfragen |
|
||||
| `erfassen` | Neue Aufgaben anlegen — mehr nicht |
|
||||
| `schreiben` | Bestehende Aufgaben ändern und abhaken |
|
||||
|
||||
Ein Dienst, der nur meldet „hier ist etwas Neues", bekommt `erfassen` und
|
||||
sonst nichts. Das ist der häufigste Fall und der, bei dem ein mitgelesener
|
||||
Token am wenigsten anrichtet.
|
||||
|
||||
## Fehler
|
||||
|
||||
| Status | Bedeutung |
|
||||
| ------ | ----------------------------------------------------------- |
|
||||
| 400 | Die Anfrage war fehlerhaft (Feldliste steht in der Antwort) |
|
||||
| 401 | Kein gültiger Token |
|
||||
| 403 | Dieser Token darf das nicht |
|
||||
| 404 | Gibt es nicht |
|
||||
|
||||
401 sieht immer gleich aus — ob ein Token unbekannt, abgelaufen oder
|
||||
zurückgezogen ist, verrät die Schnittstelle nicht. Von außen soll sich nicht
|
||||
abfragen lassen, welche Tokens es gibt.
|
||||
|
||||
## Endpunkte
|
||||
|
||||
### `GET /selbst`
|
||||
|
||||
Wer bin ich, was darf ich? Braucht **kein** Recht — sonst müsste man raten,
|
||||
ob der Token falsch ist oder nur zu wenig darf.
|
||||
|
||||
```json
|
||||
{ "name": "Wartungsplaner-Import", "rechte": ["erfassen"] }
|
||||
```
|
||||
|
||||
### `GET /aufgaben` · Recht `lesen`
|
||||
|
||||
| Parameter | Standard | Bedeutung |
|
||||
| --------- | -------- | ------------------- |
|
||||
| `grenze` | 25 | Höchstens 100 |
|
||||
| `versatz` | 0 | Zum Blättern |
|
||||
| `offen` | — | `true` oder `false` |
|
||||
|
||||
```json
|
||||
{
|
||||
"aufgaben": [
|
||||
{
|
||||
"id": "clx…",
|
||||
"titel": "#4711 Drucker klemmt",
|
||||
"notiz": null,
|
||||
"status": "OFFEN",
|
||||
"prioritaet": 3,
|
||||
"aufwandMinuten": null,
|
||||
"faelligAm": null,
|
||||
"quelle": "API",
|
||||
"ref": "ticket-4711",
|
||||
"url": "https://…",
|
||||
"erstelltAm": "2026-03-17T08:20:00.000Z",
|
||||
"bereich": null,
|
||||
"projekt": null
|
||||
}
|
||||
],
|
||||
"gesamt": 42,
|
||||
"grenze": 25,
|
||||
"versatz": 0
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /aufgaben` · Recht `erfassen`
|
||||
|
||||
```bash
|
||||
curl -X POST https://planer.example.de/api/v1/aufgaben \
|
||||
-H "Authorization: Bearer planer_…" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"titel":"Drucker klemmt","ref":"ticket-4711"}'
|
||||
```
|
||||
|
||||
| Feld | Pflicht | Bemerkung |
|
||||
| ---------------- | ------- | ----------------------------------- |
|
||||
| `titel` | ja | bis 200 Zeichen |
|
||||
| `notiz` | nein | bis 4000 Zeichen |
|
||||
| `ref` | nein | Kennung beim Aufrufer — siehe unten |
|
||||
| `url` | nein | Verweis ins Ursprungssystem |
|
||||
| `faelligAm` | nein | `JJJJ-MM-TT` |
|
||||
| `prioritaet` | nein | 1 (dringend) bis 5 (irgendwann) |
|
||||
| `aufwandMinuten` | nein | 1 bis 1440 |
|
||||
|
||||
Antwort: `201` mit der angelegten Aufgabe.
|
||||
|
||||
**`ref` macht den Aufruf wiederholbar.** Zweimal dieselbe Kennung ergibt
|
||||
**eine** Aufgabe, nicht zwei — der zweite Aufruf frischt Titel, Notiz und
|
||||
Verweis auf. Ohne `ref` entsteht jedes Mal eine neue Aufgabe; dann hast du es
|
||||
so gewollt.
|
||||
|
||||
Benutze `ref`, wann immer es drüben eine stabile Kennung gibt. Das ist der
|
||||
Unterschied zwischen einer Schnittstelle, die man nach einem Netzhänger
|
||||
gefahrlos wiederholen kann, und einer, die dabei Karteileichen anlegt.
|
||||
|
||||
### `GET /aufgaben/<id>` · Recht `lesen`
|
||||
|
||||
### `PATCH /aufgaben/<id>` · Recht `schreiben`
|
||||
|
||||
```bash
|
||||
curl -X PATCH https://planer.example.de/api/v1/aufgaben/clx… \
|
||||
-H "Authorization: Bearer planer_…" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"erledigt":true}'
|
||||
```
|
||||
|
||||
Änderbar sind `titel`, `notiz`, `faelligAm`, `prioritaet`, `aufwandMinuten`
|
||||
und `erledigt`. Mindestens ein Feld muss dabei sein.
|
||||
|
||||
## Was die Schnittstelle bewusst **nicht** kann
|
||||
|
||||
- **Bereich und Projekt setzen.** Was von außen kommt, landet in der Inbox.
|
||||
Die Einordnung ist Sache dessen, der plant, nicht dessen, der meldet —
|
||||
sonst räumt ein fremder Dienst die eigene Ordnung um.
|
||||
- **Aufgaben löschen.** Abhaken ja, löschen nein. Ein Fehler im fremden
|
||||
Skript soll keine Planung vernichten.
|
||||
- **Zeitblöcke, Bereiche, Projekte oder Kalender anfassen.**
|
||||
|
||||
Fehlt dir etwas davon, ist das eine Entscheidung und kein Versehen — sag
|
||||
Bescheid, dann wird darüber geredet.
|
||||
|
||||
## Protokoll
|
||||
|
||||
Jede **schreibende** Nutzung landet im Protokoll, mit dem Token, über den sie
|
||||
kam. Lesen nicht: Ein Dienst, der im Minutentakt abfragt, würde das Protokoll
|
||||
unbrauchbar machen.
|
||||
|
||||
Auch ein abgewiesener Zugriff wird vermerkt. Ein Dienst, der immer wieder an
|
||||
dieselbe Wand läuft, ist entweder falsch eingerichtet oder versucht etwas.
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 350 KiB |
@@ -13,34 +13,36 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
||||
|
||||
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
||||
Inbox leeren, Filter, Status und Fälligkeit
|
||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
4. **[Schnellerfassung vom Handy](erfassen.md)** – als App ablegen, auch
|
||||
ohne Netz erfassen
|
||||
5. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||
6. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||
Fortschritt
|
||||
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||
7. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||
verbinden, Belegung verstehen
|
||||
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||
8. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||
Kalender legen
|
||||
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||
9. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||
Telegram einrichten, Ruhezeiten setzen
|
||||
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
|
||||
zwischen Firma und Daheim
|
||||
10. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
|
||||
zwischen Firma und Daheim
|
||||
|
||||
## Verwaltung
|
||||
|
||||
10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||
|
||||
## Was noch kommt
|
||||
|
||||
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||
|
||||
| Thema | Meilenstein |
|
||||
| ------------------------------- | ----------- |
|
||||
| Schnellerfassung vom Handy, API | M7 |
|
||||
| MCP | M8 |
|
||||
| Thema | Meilenstein |
|
||||
| ----- | ----------- |
|
||||
| MCP | M8 |
|
||||
|
||||
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
||||
|
||||
---
|
||||
|
||||
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
|
||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Schnellerfassung vom Handy
|
||||
|
||||
Die Seite **Erfassen** ist für den Moment gemacht, in dem dir etwas einfällt
|
||||
und du keine Zeit hast, es einzusortieren: ein Feld, ein Knopf, fertig.
|
||||
|
||||

|
||||
|
||||
Alles, was hier hereinkommt, landet in der **Inbox** — ohne Bereich, ohne
|
||||
Termin. Das Einsortieren passiert später am Rechner, wenn Ruhe dafür ist.
|
||||
|
||||
## Als Anwendung ablegen
|
||||
|
||||
Der Planer lässt sich auf dem Startbildschirm ablegen und verhält sich dann
|
||||
wie eine App: eigenes Symbol, kein Browserrahmen, direkt auf dieser Seite.
|
||||
|
||||
| Gerät | Weg |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| Android (Chrome) | Menü ⋮ → **App installieren** |
|
||||
| iPhone (Safari) | Teilen-Symbol → **Zum Home-Bildschirm** |
|
||||
| Rechner (Chrome/Edge) | Symbol in der Adresszeile → **Installieren** |
|
||||
|
||||
## Ohne Netz
|
||||
|
||||
**Das ist der eigentliche Punkt dieser Seite.** Im Keller, im Aufzug, im Zug
|
||||
zwischen zwei Funkzellen: Erfasst wird trotzdem.
|
||||
|
||||
Was passiert:
|
||||
|
||||
1. Das Erfasste wird **zuerst im Gerät gemerkt**, dann verschickt. Nicht
|
||||
andersherum — sonst wäre es weg, wenn du den Browser mitten im Senden
|
||||
schließt.
|
||||
2. Ohne Netz bleibt es liegen und steht unter dem Feld als „wartet".
|
||||
3. Sobald wieder Netz da ist, geht es von selbst hinaus. Du bekommst eine
|
||||
kurze Rückmeldung.
|
||||
|
||||
Der Knopf **Jetzt versuchen** stößt das von Hand an, wenn du nicht warten
|
||||
magst.
|
||||
|
||||
> **Geht dabei etwas verloren?** Nein. Jede Erfassung bekommt eine eigene
|
||||
> Kennung, die mitgeschickt wird. Kommt dieselbe Nachricht zweimal an — weil
|
||||
> die Verbindung beim ersten Versuch abbrach, die Anfrage aber trotzdem
|
||||
> durchging —, entsteht trotzdem nur **eine** Aufgabe.
|
||||
|
||||
Die Warteschlange liegt in der Datenbank des Browsers, nicht im flüchtigen
|
||||
Speicher. Sie übersteht das Schließen des Tabs und einen Neustart des
|
||||
Geräts.
|
||||
|
||||
Damit die Seite ohne Netz überhaupt aufgeht, hält der Planer genau **diese
|
||||
eine Seite** vor. Alle anderen Seiten holt er immer frisch: Ein halb
|
||||
veralteter Planer wäre schlimmer als gar keiner — ein leeres Eingabefeld
|
||||
dagegen veraltet nicht.
|
||||
|
||||
## Warum nicht die Erfassung auf der Startseite?
|
||||
|
||||
Die gibt es weiterhin, und sie ist am Rechner meist der kürzere Weg. Der
|
||||
Unterschied ist die Verlässlichkeit: Die Schnellerfassung ist die einzige
|
||||
Stelle, die ohne Netz nichts verliert.
|
||||
|
||||
---
|
||||
|
||||
Weiter zur [Inbox](aufgaben.md) oder zurück zum [Handbuch](README.md).
|
||||
@@ -50,7 +50,11 @@ test.describe('Anmeldung', () => {
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat.
|
||||
// Alltagspunkte stehen in der Leiste, Einrichtung liegt hinter "Mehr".
|
||||
await expect(page.getByRole('link', { name: 'Aufgaben', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeHidden()
|
||||
|
||||
await page.getByText('Mehr', { exact: true }).click()
|
||||
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
||||
|
||||
await page.getByTitle('Abmelden').click()
|
||||
@@ -64,6 +68,8 @@ test.describe('Anmeldung', () => {
|
||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||
|
||||
// Das Protokoll liegt hinter "Mehr" – aufklappen, dann hin.
|
||||
await page.getByText('Mehr', { exact: true }).click()
|
||||
await page.getByRole('link', { name: 'Protokoll' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||
import { EMAIL, VERBINDUNG } from './hilfe'
|
||||
|
||||
/**
|
||||
* Die öffentliche Schnittstelle.
|
||||
*
|
||||
* Geprüft wird vor allem, was **nicht** geht: ohne Token, mit falschem
|
||||
* Token, mit zu wenig Rechten, mit abgelaufenem oder zurückgezogenem Token.
|
||||
* Der gute Fall ist schnell erzählt — die Tür ist das Interessante.
|
||||
*/
|
||||
|
||||
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||
const PRAEFIX = `E2E-API-${Date.now()}`
|
||||
|
||||
/** Legt einen Token direkt in der Datenbank an und gibt den Klartext zurück. */
|
||||
async function tokenBauen(
|
||||
benutzerId: string,
|
||||
rechte: string,
|
||||
zusatz: { gueltigBis?: Date; widerrufenAm?: Date } = {},
|
||||
): Promise<string> {
|
||||
const praefix = randomBytes(4).toString('hex')
|
||||
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
|
||||
await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId,
|
||||
name: `${PRAEFIX} ${rechte}`,
|
||||
praefix,
|
||||
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
|
||||
rechte,
|
||||
...zusatz,
|
||||
},
|
||||
})
|
||||
return klartext
|
||||
}
|
||||
|
||||
test.describe('API v1', () => {
|
||||
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
|
||||
|
||||
let benutzerId = ''
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||
benutzerId = benutzer.id
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
|
||||
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'api.' } } })
|
||||
await db.$disconnect()
|
||||
})
|
||||
|
||||
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
|
||||
const antwort = await request.get('/api/v1/aufgaben')
|
||||
expect(antwort.status()).toBe(401)
|
||||
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
|
||||
expect(antwort.headers()['www-authenticate']).toBe('Bearer')
|
||||
})
|
||||
|
||||
test('weist einen erfundenen Token ab', async ({ request }) => {
|
||||
const antwort = await request.get('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer planer_abcdefgh_${'x'.repeat(43)}` },
|
||||
})
|
||||
expect(antwort.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('sagt einem gültigen Token, was er darf', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'lesen,erfassen')
|
||||
const antwort = await request.get('/api/v1/selbst', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(antwort.ok()).toBe(true)
|
||||
expect(await antwort.json()).toMatchObject({ rechte: ['lesen', 'erfassen'] })
|
||||
})
|
||||
|
||||
test('lässt einen Nur-Lesen-Token nicht erfassen', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'lesen')
|
||||
const antwort = await request.post('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { titel: `${PRAEFIX} darf nicht` },
|
||||
})
|
||||
expect(antwort.status()).toBe(403)
|
||||
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
|
||||
})
|
||||
|
||||
test('lässt einen Nur-Erfassen-Token nicht lesen', async ({ request }) => {
|
||||
// Erfassen schließt Lesen ausdrücklich nicht ein.
|
||||
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||
const antwort = await request.get('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(antwort.status()).toBe(403)
|
||||
})
|
||||
|
||||
test('weist einen abgelaufenen Token ab', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'lesen', {
|
||||
gueltigBis: new Date(Date.now() - 60_000),
|
||||
})
|
||||
const antwort = await request.get('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(antwort.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('weist einen zurückgezogenen Token ab', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'lesen', { widerrufenAm: new Date() })
|
||||
const antwort = await request.get('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(antwort.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('erfasst, wiederholt sich nicht und ändert', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'lesen,erfassen,schreiben')
|
||||
const kopf = { Authorization: `Bearer ${token}` }
|
||||
const ref = `${PRAEFIX}-ref`
|
||||
|
||||
const erste = await request.post('/api/v1/aufgaben', {
|
||||
headers: kopf,
|
||||
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||
})
|
||||
expect(erste.status()).toBe(201)
|
||||
const angelegt = (await erste.json()) as { id: string; titel: string }
|
||||
|
||||
// Dasselbe noch einmal: eine Aufgabe, nicht zwei.
|
||||
const zweite = await request.post('/api/v1/aufgaben', {
|
||||
headers: kopf,
|
||||
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||
})
|
||||
expect(zweite.ok()).toBe(true)
|
||||
expect(await db.aufgabe.count({ where: { quelle: 'API', quelleRef: ref } })).toBe(1)
|
||||
|
||||
// Abhaken über die Schnittstelle.
|
||||
const geaendert = await request.patch(`/api/v1/aufgaben/${angelegt.id}`, {
|
||||
headers: kopf,
|
||||
data: { erledigt: true },
|
||||
})
|
||||
expect(geaendert.ok()).toBe(true)
|
||||
expect((await geaendert.json()) as { status: string }).toMatchObject({
|
||||
status: 'ERLEDIGT',
|
||||
})
|
||||
|
||||
// Und im Protokoll steht, dass es über die Schnittstelle kam.
|
||||
const protokoll = await db.auditEintrag.findFirst({
|
||||
where: { aktion: 'api.geaendert', objektId: angelegt.id },
|
||||
select: { details: true },
|
||||
})
|
||||
expect(protokoll).not.toBeNull()
|
||||
expect(JSON.stringify(protokoll?.details)).toContain('api')
|
||||
})
|
||||
|
||||
test('lehnt eine Aufgabe ohne Titel ab', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||
const antwort = await request.post('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: { titel: ' ' },
|
||||
})
|
||||
expect(antwort.status()).toBe(400)
|
||||
})
|
||||
|
||||
test('setzt weder Status noch Bereich aus der Anfrage', async ({ request }) => {
|
||||
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||
const bereich = await db.bereich.findFirst({ select: { id: true } })
|
||||
|
||||
const antwort = await request.post('/api/v1/aufgaben', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
titel: `${PRAEFIX} Einordnung bleibt hier`,
|
||||
status: 'ERLEDIGT',
|
||||
bereichId: bereich?.id ?? null,
|
||||
},
|
||||
})
|
||||
expect(antwort.status()).toBe(201)
|
||||
|
||||
const angelegt = await db.aufgabe.findFirstOrThrow({
|
||||
where: { titel: `${PRAEFIX} Einordnung bleibt hier` },
|
||||
select: { status: true, bereichId: true },
|
||||
})
|
||||
// Was von außen kommt, landet offen in der Inbox.
|
||||
expect(angelegt.status).toBe('OFFEN')
|
||||
expect(angelegt.bereichId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,10 @@ test.describe('Aufgaben', () => {
|
||||
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
|
||||
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
|
||||
|
||||
// Die Meldung kommt, bevor das Neuladen der Liste fertig ist. Wer jetzt
|
||||
// navigiert, bricht es ab — und Chromium meldet ERR_ABORTED.
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Aus der Inbox ist sie damit verschwunden.
|
||||
await page.goto('/inbox')
|
||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "2.11.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planer",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||
"type": "module",
|
||||
|
||||
@@ -489,6 +489,44 @@ async function main(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
// --- API-Tokens -----------------------------------------------------------
|
||||
// Erfundene Hashes: Mit diesen Zeilen laesst sich nichts aufrufen, sie
|
||||
// zeigen nur, wie die Liste aussieht.
|
||||
await db.apiToken.deleteMany({})
|
||||
await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: 'Wartungsplaner-Import',
|
||||
praefix: 'k7m2xq4b',
|
||||
hash: 'demo-1',
|
||||
rechte: 'erfassen',
|
||||
erstelltAm: new Date(Date.UTC(2026, 0, 14, 9, 30)),
|
||||
letzteNutzung: new Date(Date.UTC(2026, 2, 17, 5, 12)),
|
||||
},
|
||||
})
|
||||
await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: 'Auswertung Homelab',
|
||||
praefix: 'r3wp9dt4',
|
||||
hash: 'demo-2',
|
||||
rechte: 'lesen',
|
||||
gueltigBis: new Date(Date.UTC(2026, 8, 30, 22, 0)),
|
||||
erstelltAm: new Date(Date.UTC(2026, 1, 2, 18, 45)),
|
||||
},
|
||||
})
|
||||
await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: 'Altes Skript',
|
||||
praefix: 'z8n4vt6c',
|
||||
hash: 'demo-3',
|
||||
rechte: 'lesen,schreiben',
|
||||
erstelltAm: new Date(Date.UTC(2025, 10, 3, 11, 0)),
|
||||
widerrufenAm: new Date(Date.UTC(2026, 1, 20, 8, 15)),
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "api_token" (
|
||||
"id" TEXT NOT NULL,
|
||||
"benutzerId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"praefix" TEXT NOT NULL,
|
||||
"hash" TEXT NOT NULL,
|
||||
"rechte" TEXT NOT NULL,
|
||||
"gueltigBis" TIMESTAMP(3),
|
||||
"letzteNutzung" TIMESTAMP(3),
|
||||
"widerrufenAm" TIMESTAMP(3),
|
||||
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "api_token_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_token_praefix_key" ON "api_token"("praefix");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "api_token_hash_key" ON "api_token"("hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "api_token_benutzerId_idx" ON "api_token"("benutzerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "api_token" ADD CONSTRAINT "api_token_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -39,6 +39,7 @@ model Benutzer {
|
||||
pushGeraete PushGeraet[]
|
||||
meldungen Meldungsnachweis[]
|
||||
telegramChats TelegramChat[]
|
||||
apiTokens ApiToken[]
|
||||
|
||||
@@index([aktiv])
|
||||
@@map("benutzer")
|
||||
@@ -538,3 +539,34 @@ model TelegramKopplung {
|
||||
@@index([gueltigBis])
|
||||
@@map("telegram_kopplung")
|
||||
}
|
||||
|
||||
/// Zugangstoken fuer die oeffentliche Schnittstelle.
|
||||
///
|
||||
/// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim
|
||||
/// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert,
|
||||
/// legt ein neues an, statt das alte irgendwo nachzuschlagen.
|
||||
model ApiToken {
|
||||
id String @id @default(cuid())
|
||||
benutzerId String
|
||||
|
||||
name String
|
||||
/// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar.
|
||||
praefix String @unique
|
||||
/// SHA-256 des vollstaendigen Tokens.
|
||||
hash String @unique
|
||||
|
||||
/// Kommagetrennt, siehe src/lib/api.ts.
|
||||
rechte String
|
||||
|
||||
gueltigBis DateTime?
|
||||
letzteNutzung DateTime?
|
||||
/// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll.
|
||||
widerrufenAm DateTime?
|
||||
|
||||
erstelltAm DateTime @default(now())
|
||||
|
||||
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([benutzerId])
|
||||
@@map("api_token")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Planer",
|
||||
"short_name": "Planer",
|
||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke.",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"start_url": "/erfassen",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f7f8fa",
|
||||
"theme_color": "#f7f8fa",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/symbol.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/symbol-flaeche.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Erfassen",
|
||||
"short_name": "Erfassen",
|
||||
"url": "/erfassen"
|
||||
},
|
||||
{
|
||||
"name": "Heute",
|
||||
"short_name": "Heute",
|
||||
"url": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,72 @@
|
||||
/*
|
||||
* Service Worker des Planers.
|
||||
*
|
||||
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
|
||||
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
|
||||
* veralteter Planer wäre schlimmer als gar keiner.
|
||||
* Zwei Aufgaben: Push-Nachrichten anzeigen, und die Seite "Erfassen" auch
|
||||
* ohne Netz bereithalten.
|
||||
*
|
||||
* Zwischengespeichert wird **genau diese eine Seite** und sonst nichts. Ein
|
||||
* halb veralteter Planer wäre schlimmer als gar keiner — ein Eingabefeld
|
||||
* dagegen veraltet nicht, und ohne die Seite nützte die Warteschlange im
|
||||
* Gerät niemandem: Man käme gar nicht erst hin.
|
||||
*/
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
const LAGER = 'planer-erfassen-v1'
|
||||
const OFFLINE_SEITE = '/erfassen'
|
||||
|
||||
self.addEventListener('install', (ereignis) => {
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.open(LAGER)
|
||||
.then((lager) => lager.add(new Request(OFFLINE_SEITE, { cache: 'reload' })))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (ereignis) => {
|
||||
ereignis.waitUntil(self.clients.claim())
|
||||
ereignis.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((namen) =>
|
||||
Promise.all(namen.filter((n) => n !== LAGER).map((n) => caches.delete(n))),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (ereignis) => {
|
||||
const anfrage = ereignis.request
|
||||
if (anfrage.method !== 'GET') return
|
||||
|
||||
const adresse = new URL(anfrage.url)
|
||||
if (adresse.origin !== self.location.origin) return
|
||||
if (adresse.pathname !== OFFLINE_SEITE) return
|
||||
|
||||
// Zuerst das Netz: Wer online ist, bekommt die frische Seite. Erst wenn das
|
||||
// scheitert, kommt die aufgehobene Fassung.
|
||||
ereignis.respondWith(
|
||||
fetch(anfrage)
|
||||
.then((antwort) => {
|
||||
if (antwort.ok) {
|
||||
const kopie = antwort.clone()
|
||||
void caches.open(LAGER).then((lager) => lager.put(OFFLINE_SEITE, kopie))
|
||||
}
|
||||
return antwort
|
||||
})
|
||||
.catch(() =>
|
||||
caches
|
||||
.match(OFFLINE_SEITE)
|
||||
.then(
|
||||
(aufgehoben) =>
|
||||
aufgehoben ??
|
||||
new Response('Offline und nichts aufgehoben.', {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('push', (ereignis) => {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||
<!-- Maskierbare Fassung: Das Motiv sitzt in der sicheren Zone, damit ein
|
||||
Betriebssystem daraus einen Kreis oder ein Quadrat schneiden kann. -->
|
||||
<rect width="64" height="64" fill="#2563eb" />
|
||||
<path d="M20 34l8 8 16-18" fill="none" stroke="#ffffff" stroke-width="6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||
<!-- Ein Blatt mit Haken: Liste plus Erledigtes. Bewusst ohne Farbverlauf,
|
||||
damit es auch als kleines Symbol lesbar bleibt. -->
|
||||
<rect x="10" y="6" width="44" height="52" rx="8" fill="#2563eb" />
|
||||
<rect x="20" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||
<rect x="36" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||
<path d="M21 33l8 8 15-16" fill="none" stroke="#ffffff" stroke-width="6"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 573 B |
@@ -37,6 +37,7 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
{ name: 'anmelden', pfad: '/anmelden' },
|
||||
{ name: 'start', pfad: '/' },
|
||||
{ name: 'inbox', pfad: '/inbox' },
|
||||
{ name: 'erfassen', pfad: '/erfassen' },
|
||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||
{ name: 'woche', pfad: '/woche' },
|
||||
{ name: 'projekte', pfad: '/projekte' },
|
||||
@@ -50,6 +51,7 @@ const AUFNAHMEN: Aufnahme[] = [
|
||||
pfad: '/einstellungen',
|
||||
ausschnitt: '[data-bild="benachrichtigungen"]',
|
||||
},
|
||||
{ name: 'api', pfad: '/verwaltung/api' },
|
||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||
]
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ async function aufraeumen(): Promise<void> {
|
||||
})
|
||||
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
|
||||
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
|
||||
await db.apiToken.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -395,6 +396,43 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
)
|
||||
|
||||
// --- API-Tokens ----------------------------------------------------------
|
||||
const token = await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: `${kennung}-token`,
|
||||
praefix: kennung.slice(-8),
|
||||
hash: `hash-${kennung}`,
|
||||
rechte: 'lesen',
|
||||
},
|
||||
})
|
||||
pruefe('API-Token anlegen', token.id.length > 0)
|
||||
pruefe('Ein frischer Token gilt unbegrenzt', token.gueltigBis === null)
|
||||
pruefe('Ein frischer Token ist nicht widerrufen', token.widerrufenAm === null)
|
||||
|
||||
await scheitert('Dasselbe Praefix nur einmal', () =>
|
||||
db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: `${kennung}-zweiter`,
|
||||
praefix: kennung.slice(-8),
|
||||
hash: `anderer-hash-${kennung}`,
|
||||
rechte: 'lesen',
|
||||
},
|
||||
}),
|
||||
)
|
||||
await scheitert('Denselben Hash nur einmal', () =>
|
||||
db.apiToken.create({
|
||||
data: {
|
||||
benutzerId: benutzer.id,
|
||||
name: `${kennung}-dritter`,
|
||||
praefix: 'anders12',
|
||||
hash: `hash-${kennung}`,
|
||||
rechte: 'lesen',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||
|
||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||
@@ -420,6 +458,10 @@ async function main(): Promise<void> {
|
||||
'Push-Geräte verschwinden mit dem Benutzer',
|
||||
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'API-Tokens verschwinden mit dem Benutzer',
|
||||
(await db.apiToken.count({ where: { name: { startsWith: kennung } } })) === 0,
|
||||
)
|
||||
pruefe(
|
||||
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
|
||||
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { CloudOff, Loader2, Plus, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { abarbeiten, alle, anstellen, type Eintrag } from '@/lib/warteschlange'
|
||||
|
||||
/**
|
||||
* Schnellerfassung fürs Handy.
|
||||
*
|
||||
* Ein Feld, ein Knopf, große Fläche. Was erfasst wird, geht sofort hinaus —
|
||||
* und wenn kein Netz da ist, wartet es im Gerät, bis wieder eines da ist.
|
||||
*
|
||||
* Der Unterschied zur Erfassung auf der Startseite ist nicht die Optik,
|
||||
* sondern die Verlässlichkeit: Hier darf nichts verloren gehen, auch nicht
|
||||
* im Funkloch.
|
||||
*/
|
||||
export function Erfassung() {
|
||||
const [titel, setTitel] = useState('')
|
||||
const [laeuft, setLaeuft] = useState(false)
|
||||
const [wartend, setWartend] = useState<Eintrag[]>([])
|
||||
// Vor der Hydratation weiss der Server nichts vom Netz des Geraets;
|
||||
// "null" heisst schlicht: noch nicht nachgesehen.
|
||||
const [online, setOnline] = useState<boolean | null>(null)
|
||||
const feld = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const nachsehen = useCallback(async () => {
|
||||
try {
|
||||
setWartend(await alle())
|
||||
} catch {
|
||||
// Ohne IndexedDB gibt es keine Warteschlange; erfassen geht trotzdem.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const leeren = useCallback(async () => {
|
||||
const bericht = await abarbeiten()
|
||||
if (bericht.gesendet > 0) {
|
||||
toast.success(
|
||||
bericht.gesendet === 1
|
||||
? 'Eine gemerkte Aufgabe ist raus.'
|
||||
: `${bericht.gesendet} gemerkte Aufgaben sind raus.`,
|
||||
)
|
||||
}
|
||||
await nachsehen()
|
||||
}, [nachsehen])
|
||||
|
||||
useEffect(() => {
|
||||
function wiederDa() {
|
||||
setOnline(true)
|
||||
void leeren()
|
||||
}
|
||||
function weg() {
|
||||
setOnline(false)
|
||||
}
|
||||
|
||||
window.addEventListener('online', wiederDa)
|
||||
window.addEventListener('offline', weg)
|
||||
|
||||
// Den Anfangszustand nicht im Rumpf setzen, sondern als Antwort auf das
|
||||
// Aussenwelt-Ereignis "die Seite ist jetzt da".
|
||||
const ersteRunde = window.setTimeout(() => {
|
||||
setOnline(navigator.onLine)
|
||||
void nachsehen()
|
||||
void leeren()
|
||||
}, 0)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(ersteRunde)
|
||||
window.removeEventListener('online', wiederDa)
|
||||
window.removeEventListener('offline', weg)
|
||||
}
|
||||
}, [leeren, nachsehen])
|
||||
|
||||
// Der Dienstarbeiter hält die Seite selbst offline bereit. Ohne ihn wäre
|
||||
// die Warteschlange sinnlos: Man käme gar nicht erst auf die Seite.
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
void navigator.serviceWorker.register('/sw.js').catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function erfassen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault()
|
||||
const text = titel.trim()
|
||||
if (text.length === 0) return
|
||||
|
||||
setLaeuft(true)
|
||||
try {
|
||||
// Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren,
|
||||
// wenn der Browser mitten im Senden geschlossen wird.
|
||||
const eintrag = await anstellen(text)
|
||||
setTitel('')
|
||||
feld.current?.focus()
|
||||
|
||||
const bericht = await abarbeiten()
|
||||
await nachsehen()
|
||||
|
||||
toast.success(
|
||||
bericht.gesendet > 0
|
||||
? 'Erfasst. Liegt in der Inbox.'
|
||||
: 'Gemerkt. Geht raus, sobald wieder Netz da ist.',
|
||||
)
|
||||
// Der Eintrag selbst interessiert nur, wenn er liegen bleibt.
|
||||
void eintrag
|
||||
} catch {
|
||||
toast.error('Das hat nicht geklappt.')
|
||||
} finally {
|
||||
setLaeuft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={(e) => void erfassen(e)} className="flex flex-col gap-3">
|
||||
<label htmlFor="titel" className="sr-only">
|
||||
Was ist zu tun?
|
||||
</label>
|
||||
<textarea
|
||||
id="titel"
|
||||
ref={feld}
|
||||
value={titel}
|
||||
onChange={(e) => setTitel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Auf dem Handy ist Enter ein Zeilenumbruch; am Rechner will man
|
||||
// damit abschicken.
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
void erfassen(e)
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
placeholder="Was ist zu tun?"
|
||||
className="border-rand bg-karte focus-visible:ring-betont/40 w-full resize-none rounded-lg border p-4 text-base outline-none focus-visible:ring-2"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={laeuft || titel.trim().length === 0}
|
||||
className="h-12 text-base"
|
||||
>
|
||||
{laeuft ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plus aria-hidden />
|
||||
)}
|
||||
Erfassen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{online === false && (
|
||||
<p className="text-gedaempft-vordergrund flex items-center gap-2 text-sm">
|
||||
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||||
Kein Netz. Erfasstes wird gemerkt und später verschickt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{wartend.length > 0 && (
|
||||
<div className="border-rand flex flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
{wartend.length === 1 ? 'Eine wartet' : `${wartend.length} warten`}
|
||||
</p>
|
||||
<Button
|
||||
art="leise"
|
||||
groesse="klein"
|
||||
disabled={laeuft}
|
||||
onClick={() => void leeren()}
|
||||
>
|
||||
<RefreshCw aria-hidden />
|
||||
Jetzt versuchen
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
|
||||
{wartend.map((e) => (
|
||||
<li key={e.ref} className="truncate">
|
||||
{e.titel}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { Inbox } from 'lucide-react'
|
||||
import { Erfassung } from '@/app/(app)/erfassen/erfassung'
|
||||
import { zaehlstaende } from '@/server/abfragen/aufgaben'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
|
||||
export const metadata: Metadata = { title: 'Erfassen' }
|
||||
|
||||
export default async function ErfassenSeite() {
|
||||
await verlangeAnmeldung('/erfassen')
|
||||
|
||||
// Dieselbe Zaehlung wie in der Kopfleiste. Eine eigene Abfrage zaehlte
|
||||
// Ideen mit und widersprach dem Zaehler eine Zeile darueber.
|
||||
const { inbox } = await zaehlstaende()
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Erfassen</h1>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
Alles landet in der Inbox und wird später einsortiert. Ohne Netz wird es
|
||||
gemerkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Erfassung />
|
||||
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-gedaempft-vordergrund hover:text-vordergrund flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Inbox className="size-4" aria-hidden />
|
||||
{inbox === 0
|
||||
? 'Inbox ist leer'
|
||||
: inbox === 1
|
||||
? 'Eine Aufgabe in der Inbox'
|
||||
: `${inbox} Aufgaben in der Inbox`}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import { TokenVerwaltung } from '@/app/(app)/verwaltung/api/token-verwaltung'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
CardInhalt,
|
||||
CardKopf,
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
import { rechteLesen } from '@/lib/api'
|
||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { umgebung } from '@/server/umgebung'
|
||||
|
||||
export const metadata: Metadata = { title: 'API-Tokens' }
|
||||
|
||||
export default async function ApiSeite() {
|
||||
await verlangeRolle('ADMIN', '/verwaltung/api')
|
||||
|
||||
const tokens = await db.apiToken.findMany({
|
||||
// Gueltige zuerst, Zurueckgezogenes ans Ende: Was noch Zugang hat, ist
|
||||
// das, wonach man sucht.
|
||||
orderBy: [{ widerrufenAm: { sort: 'asc', nulls: 'first' } }, { erstelltAm: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
praefix: true,
|
||||
rechte: true,
|
||||
gueltigBis: true,
|
||||
letzteNutzung: true,
|
||||
widerrufenAm: true,
|
||||
erstelltAm: true,
|
||||
},
|
||||
})
|
||||
|
||||
const adresse = umgebung().APP_URL
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="text-gedaempft-vordergrund size-6" aria-hidden />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">API-Tokens</h1>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
Zugänge für fremde Anwendungen — mit so wenig Rechten wie möglich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Tokens</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Ein Token ist ein Passwort. Er wird beim Anlegen ein einziges Mal angezeigt;
|
||||
danach liegt in der Datenbank nur noch sein Fingerabdruck. Wer ihn verliert,
|
||||
legt einen neuen an — nachschlagen lässt er sich nicht.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<TokenVerwaltung
|
||||
adresse={adresse}
|
||||
tokens={tokens.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
praefix: t.praefix,
|
||||
rechte: rechteLesen(t.rechte),
|
||||
gueltigBis: t.gueltigBis ? alsDatumUhrzeit(t.gueltigBis) : null,
|
||||
letzteNutzung: t.letzteNutzung ? alsDatumUhrzeit(t.letzteNutzung) : null,
|
||||
widerrufen: t.widerrufenAm !== null,
|
||||
erstelltAm: alsDatumUhrzeit(t.erstelltAm),
|
||||
}))}
|
||||
/>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||
import { RECHTE, RECHT_BESCHREIBUNG, RECHT_NAME, type Recht } from '@/lib/api'
|
||||
import { tokenErzeugen, tokenZuruecknehmen } from '@/server/aktionen/api'
|
||||
|
||||
export type TokenAnzeige = {
|
||||
id: string
|
||||
name: string
|
||||
praefix: string
|
||||
rechte: Recht[]
|
||||
gueltigBis: string | null
|
||||
letzteNutzung: string | null
|
||||
widerrufen: boolean
|
||||
erstelltAm: string
|
||||
}
|
||||
|
||||
export function TokenVerwaltung({
|
||||
tokens,
|
||||
adresse,
|
||||
}: {
|
||||
tokens: TokenAnzeige[]
|
||||
adresse: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||
const [offen, setOffen] = useState(false)
|
||||
const [frisch, setFrisch] = useState<{ name: string; klartext: string } | null>(null)
|
||||
|
||||
function anlegen(daten: FormData) {
|
||||
starte(async () => {
|
||||
const antwort = await tokenErzeugen(daten)
|
||||
if (antwort.ok) {
|
||||
setFelder({})
|
||||
setOffen(false)
|
||||
setFrisch({ name: antwort.name, klartext: antwort.klartext })
|
||||
router.refresh()
|
||||
} else {
|
||||
setFelder(antwort.felder ?? {})
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function zurueckziehen(id: string) {
|
||||
starte(async () => {
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
const antwort = await tokenZuruecknehmen(daten)
|
||||
if (antwort.ok) {
|
||||
toast.success(antwort.meldung)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Der frisch erzeugte Token – einmalig */}
|
||||
{frisch && (
|
||||
<div className="border-betont bg-betont/5 rounded-md border p-4">
|
||||
<p className="text-sm font-medium">„{frisch.name}“ ist angelegt.</p>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-xs">
|
||||
Jetzt kopieren. Sobald du diese Seite verlässt, ist der Wert weg — in der
|
||||
Datenbank liegt nur sein Fingerabdruck.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<code className="border-rand bg-karte grow overflow-x-auto rounded-md border px-3 py-2 font-mono text-xs">
|
||||
{frisch.klartext}
|
||||
</code>
|
||||
<Button
|
||||
art="umriss"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(frisch.klartext)
|
||||
.then(() => toast.success('Kopiert.'))
|
||||
.catch(() => toast.error('Das Kopieren hat nicht geklappt.'))
|
||||
}}
|
||||
>
|
||||
<Copy aria-hidden />
|
||||
Kopieren
|
||||
</Button>
|
||||
<Button art="leise" onClick={() => setFrisch(null)}>
|
||||
Fertig
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-gedaempft-vordergrund mt-3 text-xs">
|
||||
Ausprobieren:{' '}
|
||||
<code className="text-xs">
|
||||
curl -H "Authorization: Bearer …" {adresse}/api/v1/selbst
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
Noch kein Token vergeben. Ohne Token kommt von außen nichts herein.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-rand divide-y">
|
||||
{tokens.map((token) => (
|
||||
<li key={token.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||
<KeyRound
|
||||
className={
|
||||
token.widerrufen
|
||||
? 'text-gedaempft-vordergrund size-4 shrink-0'
|
||||
: 'text-erfolg size-4 shrink-0'
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className={token.widerrufen ? 'line-through' : ''}>
|
||||
{token.name}
|
||||
</span>
|
||||
<code className="text-gedaempft-vordergrund text-xs">
|
||||
planer_{token.praefix}_…
|
||||
</code>
|
||||
{token.widerrufen && (
|
||||
<Kennzeichen art="neutral">Zurückgezogen</Kennzeichen>
|
||||
)}
|
||||
{token.rechte.map((r) => (
|
||||
<Kennzeichen key={r} art="betont">
|
||||
{RECHT_NAME[r]}
|
||||
</Kennzeichen>
|
||||
))}
|
||||
</p>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Angelegt {token.erstelltAm}
|
||||
{token.gueltigBis ? ` · gilt bis ${token.gueltigBis}` : ''}
|
||||
{token.letzteNutzung
|
||||
? ` · zuletzt benutzt ${token.letzteNutzung}`
|
||||
: ' · noch nie benutzt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!token.widerrufen && (
|
||||
<Button
|
||||
art="leise"
|
||||
groesse="quadrat"
|
||||
title="Zurückziehen"
|
||||
disabled={laeuft}
|
||||
onClick={() => zurueckziehen(token.id)}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
<span className="sr-only">Zurückziehen</span>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Neu */}
|
||||
{offen ? (
|
||||
<form
|
||||
className="border-rand flex flex-col gap-4 rounded-md border p-4"
|
||||
action={anlegen}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="name">Wofür ist der Token?</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="z. B. Wartungsplaner-Import"
|
||||
aria-invalid={Boolean(felder.name)}
|
||||
required
|
||||
/>
|
||||
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 text-sm font-medium">Was darf er?</legend>
|
||||
{RECHTE.map((recht) => (
|
||||
<label key={recht} className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rechte"
|
||||
value={recht}
|
||||
className="accent-betont mt-0.5 size-4"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-sm">{RECHT_NAME[recht]}</span>
|
||||
<span className="text-gedaempft-vordergrund block text-xs">
|
||||
{RECHT_BESCHREIBUNG[recht]}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{felder.rechte && <p className="text-gefahr text-xs">{felder.rechte}</p>}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="gueltigTage">Gültig für (Tage)</Label>
|
||||
<Input
|
||||
id="gueltigTage"
|
||||
name="gueltigTage"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
placeholder="leer = unbegrenzt"
|
||||
aria-invalid={Boolean(felder.gueltigTage)}
|
||||
/>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Ein Token mit Ablauf ist einer, den man nicht vergisst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" disabled={laeuft}>
|
||||
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||
Anlegen
|
||||
</Button>
|
||||
<Button type="button" art="leise" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<Button art="umriss" onClick={() => setOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Token anlegen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Die Annahmestelle der Schnellerfassung vom Handy.
|
||||
*
|
||||
* Getrennt von der öffentlichen Schnittstelle: Hier zählt die Sitzung, kein
|
||||
* Token. Das ist derselbe Benutzer, der auch die Oberfläche bedient — nur
|
||||
* eben über eine Seite, die auch ohne Netz funktioniert.
|
||||
*
|
||||
* Jede Erfassung trägt eine vom Gerät vergebene Kennung. Wird eine Nachricht
|
||||
* doppelt zugestellt — weil das Netz beim ersten Versuch wegbrach, die
|
||||
* Anfrage aber trotzdem ankam —, entsteht keine zweite Aufgabe.
|
||||
*/
|
||||
|
||||
const eingabe = z.object({
|
||||
titel: z.string().trim().min(1).max(200),
|
||||
/** Vom Gerät vergeben; macht den Aufruf wiederholbar. */
|
||||
ref: z.string().trim().min(8).max(64),
|
||||
})
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const benutzer = await verlangeAnmeldungFuerApi()
|
||||
if (!benutzer) {
|
||||
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
|
||||
}
|
||||
|
||||
let inhalt: unknown
|
||||
try {
|
||||
inhalt = await anfrage.json()
|
||||
} catch {
|
||||
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const gelesen = eingabe.safeParse(inhalt)
|
||||
if (!gelesen.success) {
|
||||
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { titel, ref } = gelesen.data
|
||||
|
||||
const vorhanden = await db.aufgabe.findUnique({
|
||||
where: { quelle_quelleRef: { quelle: 'MANUELL', quelleRef: ref } },
|
||||
select: { id: true, titel: true },
|
||||
})
|
||||
|
||||
// Schon da: Die Antwort ist dieselbe wie beim ersten Mal. Das Gerät soll
|
||||
// seinen Eintrag aus der Warteschlange nehmen dürfen, ohne zu grübeln.
|
||||
if (vorhanden) {
|
||||
return NextResponse.json({ ok: true, id: vorhanden.id, doppelt: true })
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.create({
|
||||
data: { titel, quelle: 'MANUELL', quelleRef: ref },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: benutzer.id, email: benutzer.email },
|
||||
aktion: 'aufgabe.erfasst',
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId: aufgabe.id,
|
||||
details: { titel, ueber: 'schnellerfassung' },
|
||||
})
|
||||
|
||||
return NextResponse.json({ ok: true, id: aufgabe.id, doppelt: false })
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { aenderungEingabe } from '@/lib/api'
|
||||
import type { Prisma } from '@/generated/prisma/client'
|
||||
import { db } from '@/server/db'
|
||||
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Eine einzelne Aufgabe lesen und ändern.
|
||||
*
|
||||
* Ändern heißt: Titel, Notiz, Fälligkeit, Priorität, Aufwand — und abhaken.
|
||||
* Bereich und Projekt bleiben außen vor; die Einordnung ist Sache dessen, der
|
||||
* plant, nicht dessen, der meldet.
|
||||
*/
|
||||
|
||||
const AUSWAHL = {
|
||||
id: true,
|
||||
titel: true,
|
||||
notiz: true,
|
||||
status: true,
|
||||
prioritaet: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
quelle: true,
|
||||
quelleRef: true,
|
||||
quelleUrl: true,
|
||||
erstelltAm: true,
|
||||
bereich: { select: { name: true } },
|
||||
projekt: { select: { name: true } },
|
||||
} as const
|
||||
|
||||
type Zeile = {
|
||||
id: string
|
||||
titel: string
|
||||
notiz: string | null
|
||||
status: string
|
||||
prioritaet: number
|
||||
aufwandMinuten: number | null
|
||||
faelligAm: Date | null
|
||||
quelle: string
|
||||
quelleRef: string | null
|
||||
quelleUrl: string | null
|
||||
erstelltAm: Date
|
||||
bereich: { name: string } | null
|
||||
projekt: { name: string } | null
|
||||
}
|
||||
|
||||
function alsAntwort(a: Zeile) {
|
||||
return {
|
||||
id: a.id,
|
||||
titel: a.titel,
|
||||
notiz: a.notiz,
|
||||
status: a.status,
|
||||
prioritaet: a.prioritaet,
|
||||
aufwandMinuten: a.aufwandMinuten,
|
||||
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
|
||||
quelle: a.quelle,
|
||||
ref: a.quelleRef,
|
||||
url: a.quelleUrl,
|
||||
erstelltAm: a.erstelltAm.toISOString(),
|
||||
bereich: a.bereich?.name ?? null,
|
||||
projekt: a.projekt?.name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const geprueft = await wache(anfrage, 'lesen')
|
||||
if (!geprueft.ok) return geprueft.antwort
|
||||
|
||||
const { id } = await params
|
||||
const aufgabe = await db.aufgabe.findUnique({ where: { id }, select: AUSWAHL })
|
||||
if (!aufgabe) return abgelehnt('nichtGefunden')
|
||||
|
||||
return daten(alsAntwort(aufgabe))
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
anfrage: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const geprueft = await wache(anfrage, 'schreiben')
|
||||
if (!geprueft.ok) return geprueft.antwort
|
||||
const { aufrufer } = geprueft
|
||||
|
||||
const { id } = await params
|
||||
const rumpf = await rumpfLesen(anfrage)
|
||||
if (rumpf === undefined) return abgelehnt('ungueltig')
|
||||
|
||||
const gelesen = aenderungEingabe.safeParse(rumpf)
|
||||
if (!gelesen.success) {
|
||||
return daten(
|
||||
{
|
||||
fehler: 'Die Anfrage war fehlerhaft.',
|
||||
felder: gelesen.error.issues.map((f) => ({
|
||||
feld: f.path.join('.'),
|
||||
grund: f.message,
|
||||
})),
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const vorhanden = await db.aufgabe.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if (!vorhanden) return abgelehnt('nichtGefunden')
|
||||
|
||||
const { erledigt, faelligAm, titel, notiz, prioritaet, aufwandMinuten } = gelesen.data
|
||||
const zeitpunkt = jetzt()
|
||||
|
||||
// Feld fuer Feld statt Spread: Mit exactOptionalPropertyTypes ist ein
|
||||
// gesetztes "undefined" etwas anderes als ein fehlendes Feld, und Prisma
|
||||
// wuerde daraus einen Schreibversuch machen.
|
||||
const werte: Prisma.AufgabeUpdateInput = {}
|
||||
if (titel !== undefined) werte.titel = titel
|
||||
if (notiz !== undefined) werte.notiz = notiz
|
||||
if (prioritaet !== undefined) werte.prioritaet = prioritaet
|
||||
if (aufwandMinuten !== undefined) werte.aufwandMinuten = aufwandMinuten
|
||||
if (faelligAm !== undefined) {
|
||||
werte.faelligAm = faelligAm === null ? null : new Date(faelligAm)
|
||||
}
|
||||
if (erledigt !== undefined) {
|
||||
werte.status = erledigt ? 'ERLEDIGT' : 'OFFEN'
|
||||
werte.erledigtAm = erledigt ? zeitpunkt : null
|
||||
}
|
||||
|
||||
const aufgabe = await db.aufgabe.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: werte,
|
||||
select: AUSWAHL,
|
||||
})
|
||||
|
||||
await apiProtokoll(aufrufer, 'api.geaendert', aufgabe.id, {
|
||||
felder: Object.keys(gelesen.data),
|
||||
})
|
||||
|
||||
return daten(alsAntwort(aufgabe))
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { abfrage, aufgabeEingabe } from '@/lib/api'
|
||||
import type { Prisma } from '@/generated/prisma/client'
|
||||
import { db } from '@/server/db'
|
||||
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Aufgaben lesen und anlegen.
|
||||
*
|
||||
* Was hier hereinkommt, landet in der **Inbox**: ohne Bereich, ohne Projekt,
|
||||
* mit Quelle `API`. Ein fremder Dienst soll melden, was es zu tun gibt — wo
|
||||
* es hingehört, wird hier entschieden.
|
||||
*/
|
||||
|
||||
/** Wie eine Aufgabe nach außen aussieht. Bewusst schmal und stabil. */
|
||||
function alsAntwort(a: {
|
||||
id: string
|
||||
titel: string
|
||||
notiz: string | null
|
||||
status: string
|
||||
prioritaet: number
|
||||
aufwandMinuten: number | null
|
||||
faelligAm: Date | null
|
||||
quelle: string
|
||||
quelleRef: string | null
|
||||
quelleUrl: string | null
|
||||
erstelltAm: Date
|
||||
bereich: { name: string } | null
|
||||
projekt: { name: string } | null
|
||||
}) {
|
||||
return {
|
||||
id: a.id,
|
||||
titel: a.titel,
|
||||
notiz: a.notiz,
|
||||
status: a.status,
|
||||
prioritaet: a.prioritaet,
|
||||
aufwandMinuten: a.aufwandMinuten,
|
||||
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
|
||||
quelle: a.quelle,
|
||||
ref: a.quelleRef,
|
||||
url: a.quelleUrl,
|
||||
erstelltAm: a.erstelltAm.toISOString(),
|
||||
bereich: a.bereich?.name ?? null,
|
||||
projekt: a.projekt?.name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const AUSWAHL = {
|
||||
id: true,
|
||||
titel: true,
|
||||
notiz: true,
|
||||
status: true,
|
||||
prioritaet: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
quelle: true,
|
||||
quelleRef: true,
|
||||
quelleUrl: true,
|
||||
erstelltAm: true,
|
||||
bereich: { select: { name: true } },
|
||||
projekt: { select: { name: true } },
|
||||
} as const
|
||||
|
||||
export async function GET(anfrage: Request) {
|
||||
const geprueft = await wache(anfrage, 'lesen')
|
||||
if (!geprueft.ok) return geprueft.antwort
|
||||
|
||||
const suche = new URL(anfrage.url).searchParams
|
||||
const gelesen = abfrage.safeParse({
|
||||
grenze: suche.get('grenze') ?? undefined,
|
||||
versatz: suche.get('versatz') ?? undefined,
|
||||
offen: suche.get('offen'),
|
||||
})
|
||||
if (!gelesen.success) return abgelehnt('ungueltig')
|
||||
|
||||
const { grenze, versatz, offen } = gelesen.data
|
||||
const wo: Prisma.AufgabeWhereInput =
|
||||
offen === null
|
||||
? {}
|
||||
: offen
|
||||
? { status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }
|
||||
: { status: { in: ['ERLEDIGT', 'VERWORFEN'] } }
|
||||
|
||||
const [zeilen, gesamt] = await Promise.all([
|
||||
db.aufgabe.findMany({
|
||||
where: wo,
|
||||
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { erstelltAm: 'desc' }],
|
||||
take: grenze,
|
||||
skip: versatz,
|
||||
select: AUSWAHL,
|
||||
}),
|
||||
db.aufgabe.count({ where: wo }),
|
||||
])
|
||||
|
||||
// Lesen wird nicht protokolliert: Es ändert nichts, und ein Dienst, der im
|
||||
// Minutentakt abfragt, würde das Protokoll unbrauchbar machen.
|
||||
return daten({ aufgaben: zeilen.map(alsAntwort), gesamt, grenze, versatz })
|
||||
}
|
||||
|
||||
export async function POST(anfrage: Request) {
|
||||
const geprueft = await wache(anfrage, 'erfassen')
|
||||
if (!geprueft.ok) return geprueft.antwort
|
||||
const { aufrufer } = geprueft
|
||||
|
||||
const rumpf = await rumpfLesen(anfrage)
|
||||
if (rumpf === undefined) return abgelehnt('ungueltig')
|
||||
|
||||
const gelesen = aufgabeEingabe.safeParse(rumpf)
|
||||
if (!gelesen.success) {
|
||||
return daten(
|
||||
{
|
||||
fehler: 'Die Anfrage war fehlerhaft.',
|
||||
felder: gelesen.error.issues.map((f) => ({
|
||||
feld: f.path.join('.'),
|
||||
grund: f.message,
|
||||
})),
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const eingabe = gelesen.data
|
||||
const werte = {
|
||||
titel: eingabe.titel,
|
||||
notiz: eingabe.notiz,
|
||||
quelle: 'API' as const,
|
||||
quelleUrl: eingabe.url,
|
||||
// Wie im Formular: Ein reines Datum wird zu Mitternacht UTC. Der Planer
|
||||
// rechnet Faelligkeiten ohnehin auf Berliner Kalendertage um.
|
||||
faelligAm: eingabe.faelligAm ? new Date(eingabe.faelligAm) : null,
|
||||
...(eingabe.prioritaet ? { prioritaet: eingabe.prioritaet } : {}),
|
||||
...(eingabe.aufwandMinuten ? { aufwandMinuten: eingabe.aufwandMinuten } : {}),
|
||||
}
|
||||
|
||||
// Mit Kennung ist der Aufruf wiederholbar: Zweimal dasselbe ergibt eine
|
||||
// Aufgabe, nicht zwei. Ohne Kennung entsteht jedes Mal eine neue – dann
|
||||
// hat der Aufrufer es so gewollt.
|
||||
const aufgabe = eingabe.ref
|
||||
? await db.aufgabe.upsert({
|
||||
where: { quelle_quelleRef: { quelle: 'API', quelleRef: eingabe.ref } },
|
||||
update: { titel: werte.titel, notiz: werte.notiz, quelleUrl: werte.quelleUrl },
|
||||
create: { ...werte, quelleRef: eingabe.ref },
|
||||
select: AUSWAHL,
|
||||
})
|
||||
: await db.aufgabe.create({ data: werte, select: AUSWAHL })
|
||||
|
||||
await apiProtokoll(aufrufer, 'api.erfasst', aufgabe.id, { ref: eingabe.ref })
|
||||
|
||||
return daten(alsAntwort(aufgabe), 201)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { tokenAusKopfzeilen } from '@/lib/api'
|
||||
import { tokenPruefen } from '@/server/api/token'
|
||||
import { abgelehnt, daten } from '@/server/api/wache'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* „Wer bin ich, und was darf ich?"
|
||||
*
|
||||
* Der erste Aufruf beim Einrichten. Er braucht **kein** Recht — sonst müsste
|
||||
* man raten, ob der Token falsch ist oder nur zu wenig darf, und genau diese
|
||||
* Ratestunde soll niemand haben.
|
||||
*/
|
||||
export async function GET(anfrage: Request) {
|
||||
const aufrufer = await tokenPruefen(
|
||||
tokenAusKopfzeilen(
|
||||
anfrage.headers.get('Authorization'),
|
||||
anfrage.headers.get('X-Planer-Token'),
|
||||
),
|
||||
)
|
||||
if (!aufrufer) return abgelehnt('nichtAngemeldet')
|
||||
|
||||
// Kein Wort über den Benutzer außer dem Namen: Die Schnittstelle ist kein
|
||||
// Adressbuch.
|
||||
return daten({ name: aufrufer.name, rechte: aufrufer.rechte })
|
||||
}
|
||||
@@ -11,6 +11,13 @@ export const metadata: Metadata = {
|
||||
description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.',
|
||||
applicationName: 'Planer',
|
||||
robots: { index: false, follow: false },
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: '/symbol.svg',
|
||||
apple: '/symbol.svg',
|
||||
},
|
||||
// Auf dem iPhone laeuft eine hinzugefuegte Seite sonst mit Browserleiste.
|
||||
appleWebApp: { capable: true, title: 'Planer', statusBarStyle: 'default' },
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { LogOut, Moon, Shield, Sun } from 'lucide-react'
|
||||
import { ChevronDown, LogOut, Moon, Shield, Sun } from 'lucide-react'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import type { Rolle } from '@/generated/prisma/enums'
|
||||
import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
|
||||
@@ -12,6 +12,16 @@ import { Button } from '@/components/ui/button'
|
||||
|
||||
type Eintrag = { pfad: string; name: string; nurAdmin?: boolean; zaehler?: number }
|
||||
|
||||
/**
|
||||
* Die Leiste traegt nur, was taeglich gebraucht wird.
|
||||
*
|
||||
* Mit jedem Meilenstein kam ein Punkt dazu, und zweimal hat die Leiste
|
||||
* daraufhin still Eintraege am rechten Rand verschluckt. Das Problem ist
|
||||
* nicht die Breite, sondern die Vermischung: Einrichtung und Verwaltung
|
||||
* sucht man nicht beim Arbeiten, sondern wenn man sie braucht. Sie liegen
|
||||
* deshalb hinter "Mehr".
|
||||
*/
|
||||
|
||||
export function Kopfleiste({
|
||||
name,
|
||||
email,
|
||||
@@ -28,20 +38,27 @@ export function Kopfleiste({
|
||||
}) {
|
||||
const EINTRAEGE: Eintrag[] = [
|
||||
{ pfad: '/', name: 'Start' },
|
||||
{ pfad: '/erfassen', name: 'Erfassen' },
|
||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||
{ pfad: '/woche', name: 'Woche' },
|
||||
{ pfad: '/projekte', name: 'Projekte' },
|
||||
{ pfad: '/ideen', name: 'Ideen' },
|
||||
]
|
||||
|
||||
const WEITERE: Eintrag[] = [
|
||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||
{ pfad: '/integrationen', name: 'Integrationen' },
|
||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||
{ pfad: '/verwaltung/api', name: 'API-Tokens', nurAdmin: true },
|
||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||
]
|
||||
|
||||
const pfad = usePathname()
|
||||
const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||||
const weitere = WEITERE.filter((e) => !e.nurAdmin || istAdmin(rolle))
|
||||
const imMenue = weitere.some((e) => pfad.startsWith(e.pfad))
|
||||
|
||||
return (
|
||||
<header className="border-rand bg-karte border-b">
|
||||
@@ -50,13 +67,7 @@ export function Kopfleiste({
|
||||
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||||
die zweite Zeile zu drängen.
|
||||
*/}
|
||||
{/*
|
||||
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
|
||||
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
|
||||
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
|
||||
ist schlimmer als eine, die nicht buendig mit dem Text steht.
|
||||
*/}
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
|
||||
<div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-4 py-3 sm:gap-5">
|
||||
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||||
Planer
|
||||
</Link>
|
||||
@@ -95,6 +106,52 @@ export function Kopfleiste({
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/*
|
||||
Ein <details> statt eines Menues aus Skript: Es klappt ohne
|
||||
JavaScript auf, schliesst sich beim Klick daneben und braucht
|
||||
keine Zustandsverwaltung.
|
||||
*/}
|
||||
<details className="group relative shrink-0" name="kopfleiste">
|
||||
<summary
|
||||
className={cn(
|
||||
'flex cursor-pointer list-none items-center gap-1 rounded-md px-3 py-1.5 text-sm whitespace-nowrap transition-colors',
|
||||
imMenue
|
||||
? 'bg-gedaempft text-vordergrund font-medium'
|
||||
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
|
||||
)}
|
||||
>
|
||||
Mehr
|
||||
<ChevronDown
|
||||
className="size-3.5 transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</summary>
|
||||
|
||||
<div className="border-rand bg-karte absolute right-0 z-20 mt-1 flex w-56 flex-col rounded-md border p-1 shadow-md">
|
||||
{weitere.map((eintrag) => {
|
||||
const aktiv = pfad.startsWith(eintrag.pfad)
|
||||
return (
|
||||
<Link
|
||||
key={eintrag.pfad}
|
||||
href={eintrag.pfad}
|
||||
aria-current={aktiv ? 'page' : undefined}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm whitespace-nowrap transition-colors',
|
||||
aktiv
|
||||
? 'bg-gedaempft text-vordergrund font-medium'
|
||||
: 'text-gedaempft-vordergrund hover:bg-gedaempft hover:text-vordergrund',
|
||||
)}
|
||||
>
|
||||
{eintrag.nurAdmin && (
|
||||
<Shield className="mr-1 inline size-3.5" aria-hidden />
|
||||
)}
|
||||
{eintrag.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
aenderungEingabe,
|
||||
abfrage,
|
||||
aufgabeEingabe,
|
||||
darf,
|
||||
rechteLesen,
|
||||
tokenAusKopfzeilen,
|
||||
tokenZerlegen,
|
||||
type Recht,
|
||||
} from '@/lib/api'
|
||||
|
||||
describe('darf', () => {
|
||||
test('lässt durch, was ausdrücklich erlaubt ist', () => {
|
||||
expect(darf(['lesen'], 'lesen')).toBe(true)
|
||||
})
|
||||
|
||||
test('Erfassen schließt Lesen nicht ein', () => {
|
||||
// Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den
|
||||
// Bestand nicht zu sehen.
|
||||
expect(darf(['erfassen'], 'lesen')).toBe(false)
|
||||
})
|
||||
|
||||
test('Schreiben schließt Erfassen nicht ein', () => {
|
||||
expect(darf(['schreiben'], 'erfassen')).toBe(false)
|
||||
})
|
||||
|
||||
test('mehrere Rechte gelten nebeneinander', () => {
|
||||
const rechte: Recht[] = ['lesen', 'erfassen']
|
||||
expect(darf(rechte, 'lesen')).toBe(true)
|
||||
expect(darf(rechte, 'erfassen')).toBe(true)
|
||||
expect(darf(rechte, 'schreiben')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rechteLesen', () => {
|
||||
test('liest die gespeicherte Liste', () => {
|
||||
expect(rechteLesen('lesen,erfassen')).toEqual(['lesen', 'erfassen'])
|
||||
})
|
||||
|
||||
test('verträgt Leerzeichen', () => {
|
||||
expect(rechteLesen(' lesen , schreiben ')).toEqual(['lesen', 'schreiben'])
|
||||
})
|
||||
|
||||
test('wirft Unbekanntes weg, statt daran zu scheitern', () => {
|
||||
// Ein Recht, das es nicht mehr gibt, darf keine Anfrage abbrechen –
|
||||
// es soll schlicht nicht gelten.
|
||||
expect(rechteLesen('lesen,zaubern')).toEqual(['lesen'])
|
||||
})
|
||||
|
||||
test('aus leer wird leer', () => {
|
||||
expect(rechteLesen('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokenZerlegen', () => {
|
||||
const gueltig = `planer_ab12cd34_${'x'.repeat(43)}`
|
||||
|
||||
test('zerlegt einen gültigen Token', () => {
|
||||
expect(tokenZerlegen(gueltig)).toEqual({
|
||||
praefix: 'ab12cd34',
|
||||
geheimnis: 'x'.repeat(43),
|
||||
})
|
||||
})
|
||||
|
||||
test('verträgt Leerzeichen drumherum', () => {
|
||||
expect(tokenZerlegen(` ${gueltig} `)?.praefix).toBe('ab12cd34')
|
||||
})
|
||||
|
||||
test('lehnt eine fremde Marke ab', () => {
|
||||
expect(tokenZerlegen(`fremd_ab12cd34_${'x'.repeat(43)}`)).toBeNull()
|
||||
})
|
||||
|
||||
test('lehnt ein zu kurzes Präfix ab', () => {
|
||||
expect(tokenZerlegen(`planer_ab12_${'x'.repeat(43)}`)).toBeNull()
|
||||
})
|
||||
|
||||
test('lehnt ein zu kurzes Geheimnis ab', () => {
|
||||
expect(tokenZerlegen('planer_ab12cd34_kurz')).toBeNull()
|
||||
})
|
||||
|
||||
test('lehnt leeren Text ab', () => {
|
||||
expect(tokenZerlegen('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tokenAusKopfzeilen', () => {
|
||||
test('liest Bearer', () => {
|
||||
expect(tokenAusKopfzeilen('Bearer abc', null)).toBe('abc')
|
||||
})
|
||||
|
||||
test('liest Bearer auch klein geschrieben', () => {
|
||||
expect(tokenAusKopfzeilen('bearer abc', null)).toBe('abc')
|
||||
})
|
||||
|
||||
test('liest die eigene Kopfzeile', () => {
|
||||
expect(tokenAusKopfzeilen(null, 'abc')).toBe('abc')
|
||||
})
|
||||
|
||||
test('Bearer geht vor', () => {
|
||||
expect(tokenAusKopfzeilen('Bearer aus-bearer', 'aus-eigener')).toBe('aus-bearer')
|
||||
})
|
||||
|
||||
test('ohne beides null', () => {
|
||||
expect(tokenAusKopfzeilen(null, null)).toBeNull()
|
||||
})
|
||||
|
||||
test('leerer Bearer zählt nicht', () => {
|
||||
expect(tokenAusKopfzeilen('Bearer ', null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('aufgabeEingabe', () => {
|
||||
test('nimmt das Nötigste an', () => {
|
||||
const gelesen = aufgabeEingabe.safeParse({ titel: 'Angebot schreiben' })
|
||||
expect(gelesen.success).toBe(true)
|
||||
expect(gelesen.success && gelesen.data.ref).toBeNull()
|
||||
})
|
||||
|
||||
test('lehnt einen leeren Titel ab', () => {
|
||||
expect(aufgabeEingabe.safeParse({ titel: ' ' }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('lehnt einen unmöglichen Aufwand ab', () => {
|
||||
// Mehr als ein Tag am Stück ist keine Aufgabe mehr, sondern ein Projekt.
|
||||
expect(aufgabeEingabe.safeParse({ titel: 'x', aufwandMinuten: 2000 }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('lehnt eine kaputte Fälligkeit ab', () => {
|
||||
expect(aufgabeEingabe.safeParse({ titel: 'x', faelligAm: 'morgen' }).success).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('kennt Status, Bereich und Projekt nicht', () => {
|
||||
const gelesen = aufgabeEingabe.safeParse({
|
||||
titel: 'x',
|
||||
status: 'ERLEDIGT',
|
||||
bereichId: 'b1',
|
||||
})
|
||||
expect(gelesen.success).toBe(true)
|
||||
expect(gelesen.success && 'status' in gelesen.data).toBe(false)
|
||||
expect(gelesen.success && 'bereichId' in gelesen.data).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aenderungEingabe', () => {
|
||||
test('nimmt eine einzelne Änderung an', () => {
|
||||
expect(aenderungEingabe.safeParse({ erledigt: true }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('lehnt eine leere Änderung ab', () => {
|
||||
expect(aenderungEingabe.safeParse({}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('abfrage', () => {
|
||||
test('setzt Voreinstellungen', () => {
|
||||
const gelesen = abfrage.safeParse({})
|
||||
expect(gelesen.success && gelesen.data).toEqual({
|
||||
grenze: 25,
|
||||
versatz: 0,
|
||||
offen: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('deckelt die Grenze', () => {
|
||||
expect(abfrage.safeParse({ grenze: '5000' }).success).toBe(false)
|
||||
})
|
||||
|
||||
test('liest offen als Wahrheitswert', () => {
|
||||
const gelesen = abfrage.safeParse({ offen: 'true' })
|
||||
expect(gelesen.success && gelesen.data.offen).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Regeln der öffentlichen Schnittstelle.
|
||||
*
|
||||
* Ein Token ist ein Passwort, das jemand in eine fremde Anwendung einträgt.
|
||||
* Deshalb gilt hier dieselbe Vorsicht wie bei der Anmeldung: so wenig Rechte
|
||||
* wie möglich, jede Nutzung nachvollziehbar, und der Klartext existiert genau
|
||||
* einmal — beim Anlegen.
|
||||
*
|
||||
* Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit.
|
||||
*/
|
||||
|
||||
// --- Rechte ----------------------------------------------------------------
|
||||
|
||||
export const RECHTE = ['lesen', 'erfassen', 'schreiben'] as const
|
||||
export type Recht = (typeof RECHTE)[number]
|
||||
|
||||
export const RECHT_NAME: Record<Recht, string> = {
|
||||
lesen: 'Lesen',
|
||||
erfassen: 'Erfassen',
|
||||
schreiben: 'Ändern',
|
||||
}
|
||||
|
||||
export const RECHT_BESCHREIBUNG: Record<Recht, string> = {
|
||||
lesen: 'Aufgaben, Bereiche und Projekte abfragen.',
|
||||
erfassen: 'Neue Aufgaben anlegen — mehr nicht.',
|
||||
schreiben: 'Bestehende Aufgaben ändern und abhaken.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Erfassen schließt Lesen **nicht** ein.
|
||||
*
|
||||
* Ein Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand
|
||||
* nicht zu sehen. Genau das ist der häufigste Fall — und der, bei dem ein
|
||||
* mitgelesenes Token am wenigsten anrichtet.
|
||||
*/
|
||||
export function darf(rechte: readonly Recht[], gebraucht: Recht): boolean {
|
||||
return rechte.includes(gebraucht)
|
||||
}
|
||||
|
||||
export function rechteLesen(gespeichert: string): Recht[] {
|
||||
return gespeichert
|
||||
.split(',')
|
||||
.map((r) => r.trim())
|
||||
.filter((r): r is Recht => (RECHTE as readonly string[]).includes(r))
|
||||
}
|
||||
|
||||
// --- Aufbau des Tokens -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* `planer_<praefix>_<geheimnis>`
|
||||
*
|
||||
* Das Präfix steht im Klartext in der Datenbank und macht die Zeile
|
||||
* auffindbar, ohne dass irgendwo der ganze Token liegt. Es taugt zugleich als
|
||||
* Wiedererkennung in der Liste — „welchen habe ich da eigentlich vergeben?".
|
||||
*
|
||||
* Der sichtbare Anfang ist auch der Grund für das Wort „planer" davor: Wer
|
||||
* einen solchen Wert versehentlich in ein öffentliches Verzeichnis schiebt,
|
||||
* soll ihn beim Durchsehen erkennen.
|
||||
*/
|
||||
export const TOKEN_MARKE = 'planer'
|
||||
export const PRAEFIX_LAENGE = 8
|
||||
|
||||
const TOKEN_MUSTER = /^planer_([A-Za-z0-9]{8})_([A-Za-z0-9_-]{32,86})$/
|
||||
|
||||
export type TokenTeile = { praefix: string; geheimnis: string }
|
||||
|
||||
export function tokenZerlegen(wert: string): TokenTeile | null {
|
||||
const treffer = TOKEN_MUSTER.exec(wert.trim())
|
||||
if (!treffer) return null
|
||||
const [, praefix, geheimnis] = treffer
|
||||
if (!praefix || !geheimnis) return null
|
||||
return { praefix, geheimnis }
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Token aus der Kopfzeile.
|
||||
*
|
||||
* Erlaubt sind `Authorization: Bearer <token>` und — weil manche Werkzeuge
|
||||
* nichts anderes können — `X-Planer-Token: <token>`.
|
||||
*/
|
||||
export function tokenAusKopfzeilen(
|
||||
autorisierung: string | null,
|
||||
eigene: string | null,
|
||||
): string | null {
|
||||
if (autorisierung?.toLowerCase().startsWith('bearer ')) {
|
||||
return autorisierung.slice(7).trim() || null
|
||||
}
|
||||
return eigene?.trim() || null
|
||||
}
|
||||
|
||||
// --- Eingaben --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Eine Aufgabe, wie sie von außen hereinkommt.
|
||||
*
|
||||
* Bewusst schmal: Status, Bereich und Projekt lassen sich über die
|
||||
* Schnittstelle nicht setzen. Was von außen kommt, landet in der Inbox und
|
||||
* wird hier einsortiert — sonst räumt ein fremder Dienst die eigene Ordnung um.
|
||||
*/
|
||||
export const aufgabeEingabe = z.object({
|
||||
titel: z.string().trim().min(1, 'Ohne Titel geht es nicht.').max(200),
|
||||
notiz: z.string().trim().max(4000).nullable().default(null),
|
||||
/**
|
||||
* Kennung beim Aufrufer. Zweimal dieselbe ergibt **eine** Aufgabe.
|
||||
*
|
||||
* Das ist der Unterschied zwischen einer Schnittstelle, die man gefahrlos
|
||||
* wiederholen kann, und einer, die bei jedem Netzhänger Karteileichen
|
||||
* anlegt.
|
||||
*/
|
||||
ref: z.string().trim().min(1).max(64).nullable().default(null),
|
||||
url: z.url().max(500).nullable().default(null),
|
||||
faelligAm: z.iso.date().nullable().default(null),
|
||||
prioritaet: z.coerce.number().int().min(1).max(5).nullable().default(null),
|
||||
aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().default(null),
|
||||
})
|
||||
|
||||
export type AufgabeEingabe = z.infer<typeof aufgabeEingabe>
|
||||
|
||||
/** Was sich an einer bestehenden Aufgabe ändern lässt. */
|
||||
export const aenderungEingabe = z
|
||||
.object({
|
||||
titel: z.string().trim().min(1).max(200).optional(),
|
||||
notiz: z.string().trim().max(4000).nullable().optional(),
|
||||
faelligAm: z.iso.date().nullable().optional(),
|
||||
prioritaet: z.coerce.number().int().min(1).max(5).optional(),
|
||||
aufwandMinuten: z.coerce.number().int().min(1).max(1440).nullable().optional(),
|
||||
erledigt: z.boolean().optional(),
|
||||
})
|
||||
.refine((w) => Object.keys(w).length > 0, 'Es wurde nichts zum Ändern angegeben.')
|
||||
|
||||
export type AenderungEingabe = z.infer<typeof aenderungEingabe>
|
||||
|
||||
export const tokenEingabe = z.object({
|
||||
name: z.string().trim().min(1, 'Der Token braucht einen Namen.').max(60),
|
||||
rechte: z.array(z.enum(RECHTE)).min(1, 'Mindestens ein Recht.'),
|
||||
/** Tage bis zum Ablauf; null heißt unbegrenzt. */
|
||||
gueltigTage: z.coerce.number().int().min(1).max(3650).nullable().default(null),
|
||||
})
|
||||
|
||||
// --- Antworten -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fehlerantworten sind knapp und immer gleich aufgebaut.
|
||||
*
|
||||
* Kein Hinweis darauf, ob ein Token unbekannt, abgelaufen oder widerrufen
|
||||
* ist: Von außen soll sich nicht abfragen lassen, welche Tokens es gibt.
|
||||
*/
|
||||
export const FEHLER = {
|
||||
nichtAngemeldet: { status: 401, fehler: 'Kein gültiger Token.' },
|
||||
nichtErlaubt: { status: 403, fehler: 'Dieser Token darf das nicht.' },
|
||||
ungueltig: { status: 400, fehler: 'Die Anfrage war fehlerhaft.' },
|
||||
nichtGefunden: { status: 404, fehler: 'Gibt es nicht.' },
|
||||
} as const
|
||||
|
||||
/** Wie viele Datensätze eine Abfrage höchstens liefert. */
|
||||
export const SEITE_MAX = 100
|
||||
export const SEITE_STANDARD = 25
|
||||
|
||||
export const abfrage = z.object({
|
||||
grenze: z.coerce.number().int().min(1).max(SEITE_MAX).default(SEITE_STANDARD),
|
||||
versatz: z.coerce.number().int().min(0).default(0),
|
||||
offen: z
|
||||
.enum(['true', 'false'])
|
||||
.nullable()
|
||||
.default(null)
|
||||
.transform((w) => (w === null ? null : w === 'true')),
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Warteschlange der Schnellerfassung.
|
||||
*
|
||||
* Was ohne Netz erfasst wird, liegt im Browser, bis es hinausgeht. IndexedDB
|
||||
* statt localStorage, weil das hier echte Daten des Benutzers sind: Ein
|
||||
* Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
|
||||
* Aufgabe — und damit genau das, was der Planer verhindern soll.
|
||||
*
|
||||
* Läuft nur im Browser.
|
||||
*/
|
||||
|
||||
const DATENBANK = 'planer-erfassung'
|
||||
const LADEN = 'warteschlange'
|
||||
const FASSUNG = 1
|
||||
|
||||
export type Eintrag = {
|
||||
/** Vom Gerät vergeben; macht das Senden wiederholbar. */
|
||||
ref: string
|
||||
titel: string
|
||||
erfasstAm: number
|
||||
/** Wie oft das Senden schon scheiterte. */
|
||||
versuche: number
|
||||
}
|
||||
|
||||
function oeffnen(): Promise<IDBDatabase> {
|
||||
return new Promise((fertig, fehler) => {
|
||||
const anfrage = indexedDB.open(DATENBANK, FASSUNG)
|
||||
anfrage.onupgradeneeded = () => {
|
||||
const db = anfrage.result
|
||||
if (!db.objectStoreNames.contains(LADEN)) {
|
||||
db.createObjectStore(LADEN, { keyPath: 'ref' })
|
||||
}
|
||||
}
|
||||
anfrage.onsuccess = () => fertig(anfrage.result)
|
||||
anfrage.onerror = () =>
|
||||
fehler(anfrage.error ?? new Error('IndexedDB ging nicht auf.'))
|
||||
})
|
||||
}
|
||||
|
||||
async function mitLaden<T>(
|
||||
art: IDBTransactionMode,
|
||||
tun: (laden: IDBObjectStore) => IDBRequest,
|
||||
): Promise<T> {
|
||||
const db = await oeffnen()
|
||||
try {
|
||||
return await new Promise<T>((fertig, fehler) => {
|
||||
const vorgang = db.transaction(LADEN, art)
|
||||
const anfrage = tun(vorgang.objectStore(LADEN))
|
||||
anfrage.onsuccess = () => fertig(anfrage.result as T)
|
||||
anfrage.onerror = () => fehler(anfrage.error ?? new Error('IndexedDB streikt.'))
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function anstellen(titel: string): Promise<Eintrag> {
|
||||
const eintrag: Eintrag = {
|
||||
ref: kennungBauen(),
|
||||
titel,
|
||||
erfasstAm: Date.now(),
|
||||
versuche: 0,
|
||||
}
|
||||
await mitLaden('readwrite', (laden) => laden.put(eintrag))
|
||||
return eintrag
|
||||
}
|
||||
|
||||
export async function alle(): Promise<Eintrag[]> {
|
||||
const eintraege = await mitLaden<Eintrag[]>('readonly', (laden) => laden.getAll())
|
||||
return eintraege.sort((a, b) => a.erfasstAm - b.erfasstAm)
|
||||
}
|
||||
|
||||
export async function abhaken(ref: string): Promise<void> {
|
||||
await mitLaden('readwrite', (laden) => laden.delete(ref))
|
||||
}
|
||||
|
||||
export async function versuchZaehlen(eintrag: Eintrag): Promise<void> {
|
||||
await mitLaden('readwrite', (laden) =>
|
||||
laden.put({ ...eintrag, versuche: eintrag.versuche + 1 }),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Kennung, die auch ohne `crypto.randomUUID` zustande kommt.
|
||||
*
|
||||
* Ältere Browser kennen die Funktion nur über HTTPS; die Warteschlange soll
|
||||
* aber auch dann noch funktionieren, wenn nichts anderes mehr geht.
|
||||
*/
|
||||
function kennungBauen(): string {
|
||||
const quelle: Crypto = crypto
|
||||
if (typeof quelle.randomUUID === 'function') return quelle.randomUUID()
|
||||
|
||||
const zufall = new Uint8Array(16)
|
||||
quelle.getRandomValues(zufall)
|
||||
return [...zufall].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt alles hinaus, was noch aussteht.
|
||||
*
|
||||
* Gibt zurück, wie viele Einträge angekommen sind. Was scheitert, bleibt
|
||||
* liegen — beim nächsten Versuch kommt es erneut dran.
|
||||
*/
|
||||
export async function abarbeiten(): Promise<{ gesendet: number; offen: number }> {
|
||||
const warteschlange = await alle()
|
||||
let gesendet = 0
|
||||
|
||||
for (const eintrag of warteschlange) {
|
||||
try {
|
||||
const antwort = await fetch('/api/erfassen', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ titel: eintrag.titel, ref: eintrag.ref }),
|
||||
})
|
||||
|
||||
if (antwort.ok) {
|
||||
await abhaken(eintrag.ref)
|
||||
gesendet++
|
||||
continue
|
||||
}
|
||||
|
||||
// 400 heißt: Das wird auch beim zehnten Mal nichts. Liegen lassen wäre
|
||||
// eine Warteschlange, die nie leer wird.
|
||||
if (antwort.status === 400) {
|
||||
await abhaken(eintrag.ref)
|
||||
continue
|
||||
}
|
||||
|
||||
// 401 und alles Übrige: später noch einmal.
|
||||
await versuchZaehlen(eintrag)
|
||||
} catch {
|
||||
// Kein Netz. Genau dafür gibt es die Warteschlange.
|
||||
await versuchZaehlen(eintrag)
|
||||
}
|
||||
}
|
||||
|
||||
return { gesendet, offen: (await alle()).length }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { tokenEingabe } from '@/lib/api'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||
import { tokenAnlegen, tokenWiderrufen } from '@/server/api/token'
|
||||
|
||||
export type Rueckmeldung =
|
||||
| { ok: true; meldung: string }
|
||||
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||
|
||||
async function akteurOderFehler() {
|
||||
try {
|
||||
// Tokens sind Zugänge zur ganzen Anwendung. Sie anzulegen ist
|
||||
// Verwaltungssache, nicht Alltag.
|
||||
return { akteur: await pruefeRolle('ADMIN') }
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof NichtErlaubt) {
|
||||
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||
}
|
||||
throw fehler
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenAntwort =
|
||||
| { ok: true; klartext: string; name: string }
|
||||
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||
|
||||
/**
|
||||
* Legt einen Token an und gibt ihn **einmal** zurück.
|
||||
*
|
||||
* Danach steht der Wert nirgends mehr — auch nicht in der Datenbank, dort
|
||||
* liegt nur sein Hash. Wer ihn verliert, legt einen neuen an.
|
||||
*/
|
||||
export async function tokenErzeugen(daten: FormData): Promise<TokenAntwort> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const gelesen = tokenEingabe.safeParse({
|
||||
name: daten.get('name'),
|
||||
rechte: daten.getAll('rechte'),
|
||||
gueltigTage: daten.get('gueltigTage') || null,
|
||||
})
|
||||
|
||||
if (!gelesen.success) {
|
||||
const felder: Record<string, string> = {}
|
||||
for (const problem of gelesen.error.issues) {
|
||||
const feld = problem.path.join('.') || 'rechte'
|
||||
felder[feld] ??= problem.message
|
||||
}
|
||||
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
|
||||
}
|
||||
|
||||
const neu = await tokenAnlegen(
|
||||
akteur.id,
|
||||
gelesen.data.name,
|
||||
gelesen.data.rechte,
|
||||
gelesen.data.gueltigTage,
|
||||
)
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'api.token-angelegt',
|
||||
objektTyp: 'ApiToken',
|
||||
objektId: neu.id,
|
||||
// Name, Rechte und Präfix – nie der Token selbst.
|
||||
details: {
|
||||
name: gelesen.data.name,
|
||||
rechte: gelesen.data.rechte.join(','),
|
||||
praefix: neu.praefix,
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/verwaltung/api')
|
||||
return { ok: true, klartext: neu.klartext, name: gelesen.data.name }
|
||||
}
|
||||
|
||||
/** Zieht einen Token zurück. */
|
||||
export async function tokenZuruecknehmen(daten: FormData): Promise<Rueckmeldung> {
|
||||
const geprueft = await akteurOderFehler()
|
||||
if ('antwort' in geprueft) return geprueft.antwort
|
||||
const { akteur } = geprueft
|
||||
|
||||
const id = daten.get('id')
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||
}
|
||||
|
||||
const geklappt = await tokenWiderrufen(id, akteur.id)
|
||||
if (!geklappt) return { ok: false, fehler: 'Diesen Token gibt es nicht mehr.' }
|
||||
|
||||
await auditSchreiben({
|
||||
akteur: { id: akteur.id, email: akteur.email },
|
||||
aktion: 'api.token-widerrufen',
|
||||
objektTyp: 'ApiToken',
|
||||
objektId: id,
|
||||
})
|
||||
|
||||
revalidatePath('/verwaltung/api')
|
||||
return { ok: true, meldung: 'Der Token gilt nicht mehr.' }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'server-only'
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import {
|
||||
PRAEFIX_LAENGE,
|
||||
TOKEN_MARKE,
|
||||
rechteLesen,
|
||||
tokenZerlegen,
|
||||
type Recht,
|
||||
} from '@/lib/api'
|
||||
import { db } from '@/server/db'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
/**
|
||||
* Tokens für die öffentliche Schnittstelle.
|
||||
*
|
||||
* Gespeichert wird nur der Hash. Ein Token ist ein Passwort — es gibt keinen
|
||||
* Grund, es zurücklesen zu können, und jeden Grund, es nicht zu können.
|
||||
*
|
||||
* SHA-256 ohne Salz und ohne Streckung ist hier richtig, anders als bei
|
||||
* Passwörtern: Der Wert hat 32 Byte Zufall aus einer sicheren Quelle. Gegen
|
||||
* so etwas hilft kein Wörterbuch, und die Prüfung muss bei jeder Anfrage
|
||||
* schnell sein.
|
||||
*/
|
||||
|
||||
/** Ziffern und Kleinbuchstaben — das Präfix wird auch mal abgetippt. */
|
||||
const PRAEFIX_ZEICHEN = 'abcdefghijkmnopqrstuvwxyz23456789'
|
||||
|
||||
function praefixBauen(): string {
|
||||
const bytes = randomBytes(PRAEFIX_LAENGE)
|
||||
return [...bytes].map((b) => PRAEFIX_ZEICHEN[b % PRAEFIX_ZEICHEN.length]).join('')
|
||||
}
|
||||
|
||||
export function tokenHashen(wert: string): string {
|
||||
return createHash('sha256').update(wert, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
export type NeuerToken = {
|
||||
id: string
|
||||
/** Nur hier, nur jetzt. Danach steht der Wert nirgends mehr. */
|
||||
klartext: string
|
||||
praefix: string
|
||||
}
|
||||
|
||||
/** Legt einen Token an und gibt ihn ein einziges Mal heraus. */
|
||||
export async function tokenAnlegen(
|
||||
benutzerId: string,
|
||||
name: string,
|
||||
rechte: readonly Recht[],
|
||||
gueltigTage: number | null,
|
||||
): Promise<NeuerToken> {
|
||||
const praefix = praefixBauen()
|
||||
const geheimnis = randomBytes(32).toString('base64url')
|
||||
const klartext = `${TOKEN_MARKE}_${praefix}_${geheimnis}`
|
||||
|
||||
const zeile = await db.apiToken.create({
|
||||
data: {
|
||||
benutzerId,
|
||||
name,
|
||||
praefix,
|
||||
hash: tokenHashen(klartext),
|
||||
rechte: rechte.join(','),
|
||||
gueltigBis:
|
||||
gueltigTage === null
|
||||
? null
|
||||
: new Date(jetzt().getTime() + gueltigTage * 86_400_000),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return { id: zeile.id, klartext, praefix }
|
||||
}
|
||||
|
||||
export type Aufrufer = {
|
||||
tokenId: string
|
||||
benutzerId: string
|
||||
email: string
|
||||
name: string
|
||||
rechte: Recht[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Token und gibt den Aufrufer zurück.
|
||||
*
|
||||
* Alle Fehlschläge sehen von außen gleich aus: unbekannt, abgelaufen,
|
||||
* widerrufen oder zu einem gesperrten Konto gehörend. Wer probiert, soll
|
||||
* nicht erfahren, welcher Fall vorliegt.
|
||||
*/
|
||||
export async function tokenPruefen(wert: string | null): Promise<Aufrufer | null> {
|
||||
if (!wert) return null
|
||||
|
||||
const teile = tokenZerlegen(wert)
|
||||
if (!teile) return null
|
||||
|
||||
const zeile = await db.apiToken.findUnique({
|
||||
where: { praefix: teile.praefix },
|
||||
select: {
|
||||
id: true,
|
||||
hash: true,
|
||||
rechte: true,
|
||||
gueltigBis: true,
|
||||
widerrufenAm: true,
|
||||
benutzer: { select: { id: true, email: true, name: true, aktiv: true } },
|
||||
},
|
||||
})
|
||||
if (!zeile) return null
|
||||
|
||||
// Auch wenn das Präfix stimmt: Der Rest muss ebenfalls passen, und der
|
||||
// Vergleich läuft in gleichbleibender Zeit.
|
||||
const erwartet = Buffer.from(zeile.hash, 'utf8')
|
||||
const bekommen = Buffer.from(tokenHashen(wert.trim()), 'utf8')
|
||||
if (erwartet.length !== bekommen.length) return null
|
||||
if (!timingSafeEqual(erwartet, bekommen)) return null
|
||||
|
||||
const zeitpunkt = jetzt()
|
||||
if (zeile.widerrufenAm) return null
|
||||
if (zeile.gueltigBis && zeile.gueltigBis.getTime() < zeitpunkt.getTime()) return null
|
||||
if (!zeile.benutzer.aktiv) return null
|
||||
|
||||
// Nicht auf das Schreiben warten: Die Anfrage soll deswegen nicht langsamer
|
||||
// werden, und ein verlorener Zeitstempel ist zu verschmerzen.
|
||||
void db.apiToken
|
||||
.update({ where: { id: zeile.id }, data: { letzteNutzung: zeitpunkt } })
|
||||
.catch(() => undefined)
|
||||
|
||||
return {
|
||||
tokenId: zeile.id,
|
||||
benutzerId: zeile.benutzer.id,
|
||||
email: zeile.benutzer.email,
|
||||
name: zeile.benutzer.name ?? zeile.benutzer.email,
|
||||
rechte: rechteLesen(zeile.rechte),
|
||||
}
|
||||
}
|
||||
|
||||
/** Zieht einen Token zurück. Die Zeile bleibt — für das Protokoll. */
|
||||
export async function tokenWiderrufen(id: string, benutzerId: string): Promise<boolean> {
|
||||
const zeile = await db.apiToken.findFirst({
|
||||
where: { id, benutzerId, widerrufenAm: null },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!zeile) return false
|
||||
|
||||
await db.apiToken.update({
|
||||
where: { id: zeile.id },
|
||||
data: { widerrufenAm: jetzt() },
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'server-only'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { FEHLER, darf, tokenAusKopfzeilen, type Recht } from '@/lib/api'
|
||||
import { auditSchreiben } from '@/server/audit'
|
||||
import { tokenPruefen, type Aufrufer } from '@/server/api/token'
|
||||
|
||||
/**
|
||||
* Die Tür zur öffentlichen Schnittstelle.
|
||||
*
|
||||
* Jede Route geht hier durch, und jede Route protokolliert. Eine
|
||||
* Schnittstelle, bei der sich hinterher nicht sagen lässt, wer was geändert
|
||||
* hat, ist in einer Anwendung mit fremden Zugängen nicht zu verantworten.
|
||||
*/
|
||||
|
||||
export type Wachergebnis =
|
||||
{ ok: true; aufrufer: Aufrufer } | { ok: false; antwort: NextResponse }
|
||||
|
||||
function fehler(art: keyof typeof FEHLER): NextResponse {
|
||||
const { status, fehler: text } = FEHLER[art]
|
||||
return NextResponse.json(
|
||||
{ fehler: text },
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
|
||||
...(status === 401 ? { 'WWW-Authenticate': 'Bearer' } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Prüft Token und Recht in einem Zug. */
|
||||
export async function wache(anfrage: Request, gebraucht: Recht): Promise<Wachergebnis> {
|
||||
const wert = tokenAusKopfzeilen(
|
||||
anfrage.headers.get('Authorization'),
|
||||
anfrage.headers.get('X-Planer-Token'),
|
||||
)
|
||||
|
||||
const aufrufer = await tokenPruefen(wert)
|
||||
if (!aufrufer) return { ok: false, antwort: fehler('nichtAngemeldet') }
|
||||
|
||||
if (!darf(aufrufer.rechte, gebraucht)) {
|
||||
// Das Fehlen eines Rechts wird protokolliert: Ein Dienst, der immer
|
||||
// wieder an dieselbe Wand läuft, ist entweder falsch eingerichtet oder
|
||||
// versucht etwas.
|
||||
await auditSchreiben({
|
||||
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
|
||||
aktion: 'api.abgewiesen',
|
||||
objektTyp: 'ApiToken',
|
||||
objektId: aufrufer.tokenId,
|
||||
details: { gebraucht, hat: aufrufer.rechte.join(',') },
|
||||
})
|
||||
return { ok: false, antwort: fehler('nichtErlaubt') }
|
||||
}
|
||||
|
||||
return { ok: true, aufrufer }
|
||||
}
|
||||
|
||||
/** Antwort mit Daten. Nie zwischenspeichern — die Liste ändert sich ständig. */
|
||||
export function daten(inhalt: unknown, status = 200): NextResponse {
|
||||
return NextResponse.json(inhalt, {
|
||||
status,
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
})
|
||||
}
|
||||
|
||||
export function abgelehnt(art: keyof typeof FEHLER): NextResponse {
|
||||
return fehler(art)
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Rumpf als JSON, ohne bei Unsinn zu stolpern.
|
||||
*
|
||||
* Gibt `undefined` zurueck, wenn sich nichts lesen liess — das ist von
|
||||
* einem gelesenen `null` zu unterscheiden.
|
||||
*/
|
||||
export async function rumpfLesen(anfrage: Request): Promise<unknown> {
|
||||
try {
|
||||
return (await anfrage.json()) as unknown
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt einen Protokolleintrag für eine Nutzung der Schnittstelle. */
|
||||
export async function apiProtokoll(
|
||||
aufrufer: Aufrufer,
|
||||
aktion: string,
|
||||
objektId: string | null,
|
||||
details?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await auditSchreiben({
|
||||
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
|
||||
aktion,
|
||||
objektTyp: 'Aufgabe',
|
||||
objektId,
|
||||
// Der Name des Tokens gehört dazu: Sonst steht im Protokoll nur, dass
|
||||
// "der Benutzer" es war — und das war er ja gerade nicht selbst.
|
||||
details: { ueber: 'api', token: aufrufer.tokenId, ...details },
|
||||
})
|
||||
}
|
||||