Merge: Meilenstein 7 – Schnellerfassung und API (0.7.0)
Zwei Wege hinein, die es bisher nicht gab: eine Erfassungsseite fuers Handy, die auch im Funkloch nichts verliert, und eine oeffentliche Schnittstelle fuer fremde Anwendungen. Die Schnellerfassung merkt sich Erfasstes zuerst im Geraet und schickt es dann. Andersherum waere es weg, wenn der Browser mitten im Senden geschlossen wird. Jede Erfassung traegt eine Kennung, damit eine doppelt zugestellte Nachricht keine zweite Aufgabe ergibt. Von einem Token liegt nur der Hash in der Datenbank; der Wert erscheint genau einmal. Keines der drei Rechte schliesst ein anderes ein — ein Dienst, der nur meldet, braucht den Bestand nicht zu sehen. Was von aussen kommt, landet in der Inbox: Die Einordnung bleibt bei dem, der plant. Nebenbei ist die Navigation geteilt worden. Sie hatte mit dem dreizehnten Eintrag erneut Punkte am rechten Rand verschluckt; sie nur zu verbreitern war nach M6 die falsche Antwort. Geprueft vor dem Merge: npm run pruefen mit 331 Unit-Tests, 62 Integrationspruefungen, Migrationstest und 33 E2E-Tests. Offen: Die Handbuchbilder der Erfassungsseite zeigen noch eine falsche Inbox-Zahl. Der Fehler selbst ist behoben, nur der Bildlauf kam danach nicht mehr durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -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
|
||||
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 |
|
||||
|
||||
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 },
|
||||
})
|
||||
}
|
||||