Compare commits
@@ -15,6 +15,59 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.4.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Abgleich je Host und nächtlich per Zeitplan (`SSHKV_SYNC_CRON`): erkennt fehlende Schlüssel, von Hand geänderte Blöcke, unbekannte Schlüssel, falsche Rechte, beschädigte Blöcke und veränderte Private Keys; Befunde öffnen und schließen sich selbst. Hostseite mit Abweichungen und Ist-Zustand je Benutzer, „Jetzt abgleichen“ auch auf der Übersicht (#021)
|
||||||
|
- Unbekannte Schlüssel übernehmen: als bekannt aufnehmen oder per Dry-Run in den verwalteten Block holen (#022)
|
||||||
|
- Geführte Rotation: neuer Schlüssel an allen Stellen, Verbindungstests, erst dann wird der alte entfernt; jederzeit fortsetzbar, Rotationskette auf der Schlüsselseite (#023)
|
||||||
|
- Widerruf mit einem Klick und Massenwiderruf: Public Key, Private Key, config- und known_hosts-Einträge entfernen, optional Zielkonten; Zwischenzustand, solange ein Host nicht erreicht wird; abgelaufene Schlüssel mit Hinweis (#024)
|
||||||
|
- Zuweisungsmatrix Schlüssel × Host mit Filtern, Zell-Dialog zum Zuweisen und CSV-Export (#025)
|
||||||
|
- Skript zum Abgleich der Issues nach Gitea (`pnpm issues:sync`, #030 – Zugang steht noch aus)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Abgleich liest Dateien als der jeweilige Benutzer und ändert auf dem Host nichts
|
||||||
|
- Zeilen außerhalb des verwalteten Blocks entfernt sshkv nur bei ausdrücklich übernommenen Schlüsseln
|
||||||
|
- Ein Widerruf gilt erst als abgeschlossen, wenn der Schlüssel wirklich überall entfernt ist; eine Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests
|
||||||
|
- CSV-Export entschärft Werte, die eine Tabellenkalkulation als Formel lesen würde
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
|
||||||
|
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
|
||||||
|
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
|
||||||
|
- Übersicht verlinkt den Assistenten
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
|
||||||
|
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
|
||||||
|
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
|
||||||
|
|
||||||
|
## [0.2.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schlüssel erzeugen (ed25519, RSA-4096, optional mit Passphrase), Paar oder Public Key importieren, Public Key exportieren; Liste mit Filter, Detailseite mit Verwendungen (#012)
|
||||||
|
- Personen verwalten; Self-Service „Meine Schlüssel“: jeder Benutzer hinterlegt eigene Public Keys, wirksam erst nach Zuweisung (#013)
|
||||||
|
- Private Key anzeigen und herunterladen – nur Admin, nur nach erneuter Bestätigung, genau einmal abholbar; Private Key aus SSHKeyVault löschen (#014)
|
||||||
|
- authorized_keys-Parser und verwalteter Block: fremde Zeilen bleiben bytegenau, beschädigte Blöcke werden nie überschrieben, Deployment-Key ist vor dem Entfernen geschützt (#015)
|
||||||
|
- Public Keys Hosts oder Hostgruppen zuweisen, Dry-Run mit Diff, Ausrollen mit Backup und optimistischer Sperre; Ansicht je Schlüssel und je Host (#016)
|
||||||
|
- Private Key auf einen Quell-Host ausrollen, mit verwaltetem Block in `~/.ssh/config` und `known_hosts` (#017)
|
||||||
|
- Verbindungstest von Host A nach Host B (#020, Teil)
|
||||||
|
- Zielbenutzer anlegen und sudoers pflegen – Grundlage, Oberfläche folgt (#018, Teil)
|
||||||
|
- Vorlagen: Platzhalter und Prüfung – Grundlage, Oberfläche folgt (#019, Teil)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Importierte Private Keys reisen nur verschlüsselt im Job; Vorgang, Protokoll und Browser sehen sie nie (Tests)
|
||||||
|
- Private-Key-Ziele nimmt der Dry-Run nur für Admins auf
|
||||||
|
- Dateien in fremden Home-Verzeichnissen werden als deren Benutzer gelesen und geschrieben (Symlink-Schutz)
|
||||||
|
|
||||||
## [0.1.0] – 2026-10-12
|
## [0.1.0] – 2026-10-12
|
||||||
|
|
||||||
Phase 1: Grundgerüst, Anmeldung, Hosts und Bootstrap.
|
Phase 1: Grundgerüst, Anmeldung, Hosts und Bootstrap.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 1 h 25 min
|
**Gesamt (Wandzeit):** 3 h 40 min
|
||||||
**Summe der Issue-Zeiten:** 4 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008 und #011.
|
**Summe der Issue-Zeiten:** 11 h 03 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -19,10 +19,28 @@
|
|||||||
| #009 Host-Key-Prüfung | 0 h 25 min |
|
| #009 Host-Key-Prüfung | 0 h 25 min |
|
||||||
| #010 Bootstrap | 0 h 35 min |
|
| #010 Bootstrap | 0 h 35 min |
|
||||||
| #011 Test-SSH-Container | 0 h 15 min |
|
| #011 Test-SSH-Container | 0 h 15 min |
|
||||||
|
| #012 Schlüssel erzeugen, importieren, exportieren | 0 h 25 min |
|
||||||
|
| #013 Personen und Self-Service | 0 h 10 min |
|
||||||
|
| #014 Private Key anzeigen | 0 h 10 min |
|
||||||
|
| #015 authorized_keys-Parser | 0 h 15 min |
|
||||||
|
| #016 Public Key ausrollen | 0 h 40 min |
|
||||||
|
| #017 Private Key ausrollen | 0 h 45 min |
|
||||||
|
| #018 Zielbenutzer und sudoers | 0 h 30 min |
|
||||||
|
| #019 Vorlagen | 0 h 20 min |
|
||||||
|
| #020 Host-zu-Host-Assistent | 0 h 50 min |
|
||||||
|
| #021 Abgleich und Drift-Erkennung | 0 h 45 min |
|
||||||
|
| #022 Import bestehender Schlüssel | 0 h 20 min |
|
||||||
|
| #023 Geführte Rotation | 0 h 20 min |
|
||||||
|
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
|
||||||
|
| #025 Zuweisungsmatrix | 0 h 30 min |
|
||||||
|
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,3 +73,56 @@
|
|||||||
- **Eigene Testdatenbank je parallelem Lauf:** Sub-Agent und Hauptsitzung leerten sich gegenseitig die Tabellen (in `CLAUDE.md` festgehalten).
|
- **Eigene Testdatenbank je parallelem Lauf:** Sub-Agent und Hauptsitzung leerten sich gegenseitig die Tabellen (in `CLAUDE.md` festgehalten).
|
||||||
- **Prisma-Client in den Worker gebündelt:** Im Standalone-Abbild fand die ESM-Auflösung `@prisma/client` sonst nicht (erst im echten Docker-Stapel aufgefallen).
|
- **Prisma-Client in den Worker gebündelt:** Im Standalone-Abbild fand die ESM-Auflösung `@prisma/client` sonst nicht (erst im echten Docker-Stapel aufgefallen).
|
||||||
- **Worker-Benutzer in Compose wählbar** (`SSHKV_WORKER_USER`), weil Docker-Secrets ohne Swarm die Rechte der Host-Datei tragen.
|
- **Worker-Benutzer in Compose wählbar** (`SSHKV_WORKER_USER`), weil Docker-Secrets ohne Swarm die Rechte der Host-Datei tragen.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 2 · 10:07–11:07
|
||||||
|
**Issue:** #012–#020
|
||||||
|
**Version:** 0.2.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #012–#014 (Schlüssel, Personen/Self-Service, Private Key anzeigen) von einem Sub-Agenten in eigenem Worktree, danach gemergt; Konflikte in `useVorgang` und der Handler-Tabelle zusammengeführt
|
||||||
|
- #015 authorized_keys-Parser und Merge mit 26 Randfall-Tests
|
||||||
|
- #016 Public Keys ausrollen: Soll, Trockenlauf, Ausrollen, Hostseite, Schlüsselseite – Integrationstests und E2E
|
||||||
|
- #017 Private Key mit `~/.ssh/config` und `known_hosts` – Integrationstest „A meldet sich bei B an“, E2E für den ganzen Kern-Anwendungsfall
|
||||||
|
- Vorgezogen: #018 Zielkonto-Skripte (Integrationstests), #019 Vorlagen-Logik, #020 Verbindungstest
|
||||||
|
- Stand: 410 Unit-/DB-Tests, 30 Integrationstests, 12 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Phase 3 teilweise vorgezogen,** weil Florian „weiter“ sagte, während der Agent noch an Phase 2 arbeitete – nur Teile ohne Schemaänderung, damit die Migrationen des Agenten nicht kollidieren.
|
||||||
|
- **Trockenlauf und Ausrollen als zwei Vorgänge mit Hash-Sperre:** Es wird nie etwas anderes geschrieben, als jemand gesehen hat.
|
||||||
|
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
|
||||||
|
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
|
||||||
|
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 3 · 11:06–11:36
|
||||||
|
**Issue:** #018–#020
|
||||||
|
**Version:** 0.3.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
|
||||||
|
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
|
||||||
|
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
|
||||||
|
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
|
||||||
|
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
|
||||||
|
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
|
||||||
|
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 4 · 12:50–13:35
|
||||||
|
**Issue:** #021–#025, #030
|
||||||
|
**Version:** 0.4.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- Zweites, getrenntes Paar SSH-Testziele (`sshkv-test-b`, Ports 2231/2232, Mock-OIDC 8091) für den Rotation/Widerruf-Agenten, damit Integrationstests sich nicht gegenseitig zurücksetzen
|
||||||
|
- #021 Abgleich mit reiner Befundlogik, Worker-Ablauf, Zeitplan; #022 Übernahme mit neuem Feld `ausserhalbEntfernen` – Integrationstests und E2E
|
||||||
|
- #023/#024 Rotation und Widerruf (Sub-Agent) mit Zwischenzustand `widerrufEingeleitetAm`; #025 Zuweisungsmatrix (Sub-Agent)
|
||||||
|
- #030 Gitea-Skript bereitgelegt (Trockenlauf läuft)
|
||||||
|
- Stand: 470 Unit-/DB-Tests, 38 Integrationstests, 21 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Abgleich ändert nichts auf dem Host:** Er meldet nur; Korrekturen laufen über Dry-Run und Ausrollen, damit ein Mensch sie sieht.
|
||||||
|
- **Bekannte Schlüssel außerhalb des Blocks sind kein Befund** – nur unbekannte. Sonst meldete jeder bewusst stehen gelassene Admin-Key dauerhaft Drift.
|
||||||
|
- **Widerruf mit Zwischenzustand:** Ein Schlüssel wird erst WIDERRUFEN, wenn er überall entfernt ist; ein nicht erreichbarer Host lässt ihn erkennbar „unvollständig“.
|
||||||
|
- **Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests**; doppelte Aliase in `~/.ssh/config`: die jüngste Zuweisung gewinnt.
|
||||||
|
- **Migrationszeitstempel in Ortszeit-Reihenfolge** auch bei parallel entstandenen Migrationen (`…130000` Agent, `…131000` Hauptsitzung).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -1,23 +1,60 @@
|
|||||||
# #012 Schlüssel erzeugen, importieren, exportieren
|
# #012 Schlüssel erzeugen, importieren, exportieren
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Liste mit Filter
|
- [x] Liste mit Filter
|
||||||
- [ ] Erzeugen-Dialog
|
- [x] Erzeugen-Dialog
|
||||||
- [ ] Import mit Prüfung
|
- [x] Import mit Prüfung
|
||||||
- [ ] Export Public Key
|
- [x] Export Public Key
|
||||||
- [ ] Schlüsseldetail-Gerüst mit Verwendungen
|
- [x] Schlüsseldetail-Gerüst mit Verwendungen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Private Keys erscheinen nie in Listen-Antworten
|
- [x] Private Keys erscheinen nie in Listen-Antworten
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#006
|
#006
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Migration `20261012100000_schluessel_vorgaenge`:** Vorgangsarten `SCHLUESSEL_ERZEUGEN`,
|
||||||
|
`SCHLUESSEL_IMPORTIEREN`, `PRIVATE_KEY_LOESCHEN` (für #014 gleich mit), CHECK
|
||||||
|
`schluessel_slug_gueltig` (`^[a-z0-9-]{1,64}$`, der Slug landet in Dateinamen) und
|
||||||
|
`schluessel_geloescht_ohne_private_key`.
|
||||||
|
- **Erzeugen und Paar-Import laufen im Worker** (`src/worker/ablaeufe/schluessel.ts`). Das Web
|
||||||
|
prüft nur Metadaten (Slug frei, Person/Vorlage vorhanden, Ablauf nicht in der Vergangenheit)
|
||||||
|
und reiht den Vorgang ein. Passphrase und importierter Private Key reisen wie das
|
||||||
|
Bootstrap-Passwort mit `SSHKV_APP_SECRET` verschlüsselt im Job (`geheim`), nie in
|
||||||
|
`Vorgang.eingabe`; der Worker löscht den Job danach. Der Worker prüft die Metadaten erneut.
|
||||||
|
- **Kategorie:** Erzeugen und Paar-Import legen immer `DIENST` an. Personen-Keys speichert sshkv
|
||||||
|
nie mit Private Key (DB-CHECK), Deployment-Keys verwaltet der Worker selbst. Der
|
||||||
|
Public-Key-Import kennt `PERSON` und `DIENST`.
|
||||||
|
- **Passphrase:** beim Erzeugen optional; gespeichert wird dann der passphrasengeschützte
|
||||||
|
OpenSSH-Key (`passphraseGeschuetzt = true`). Beim Import dient sie nur zum Lesen von Public Key
|
||||||
|
und Fingerprint und wird verworfen; gespeichert wird der Key so, wie er kam. Hinweis in der
|
||||||
|
Oberfläche: „Für automatische Anmeldungen (Host-zu-Host) ungeeignet.“
|
||||||
|
- **Typ:** `RSA4096` nur bei genau 4096 Bit, sonst `ANDERER` (z. B. RSA-3072, ecdsa). Die RSA-
|
||||||
|
Mindestlänge 2048 gilt beim Import.
|
||||||
|
- **Slug:** aus dem Zweck abgeleitet (Umlaute gefaltet), bei Kollision `-2`, `-3` …; ein
|
||||||
|
ausdrücklich gewünschter Kurzname muss frei sein. Slug, Typ und Kommentar sind danach nicht
|
||||||
|
mehr änderbar – sie stehen schon in Dateinamen und authorized_keys-Zeilen.
|
||||||
|
- **Status von Hand** nur `AKTIV` ↔ `ABGELAUFEN`; `ROTIERT`/`WIDERRUFEN` setzen #023/#024,
|
||||||
|
Deployment-Keys sind ausgenommen (sonst erzeugte der Worker beim Start einen neuen).
|
||||||
|
- **Ablaufdatum** ohne Angabe aus `Vorlage.standardLaufzeitTage`.
|
||||||
|
- **„Private Key gespeichert?“** liest das Web aus `masterKeyVersion` (der CHECK
|
||||||
|
`schluessel_umschlag_vollstaendig` koppelt sie an das Chiffrat) – das Chiffrat wird im Web
|
||||||
|
nie selektiert.
|
||||||
|
- **Grenze abgesichert:** `tests/schluessel-grenze.test.ts` verlangt für jeden zeilenliefernden
|
||||||
|
`prisma.schluessel.*`-Aufruf außerhalb von `src/worker` ein `select` und verbietet
|
||||||
|
`schluessel: true` als Relation; `tests/schluessel.test.ts` prüft, dass weder Vorgang,
|
||||||
|
Protokoll noch pg-boss-Job nach Erzeugen/Import den Key oder die Passphrase enthalten.
|
||||||
|
- **Platzhalter** für #016/#017: `src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx` und
|
||||||
|
`PrivateAusrollenAktion.tsx`, Props `{ schluesselId: string; darf: boolean }`, rendern `null`.
|
||||||
|
- Personen-Keys zeigen im Detail nur die Karte „Public Key ausgerollt auf“ (eine Private-Karte
|
||||||
|
wäre immer leer).
|
||||||
|
- Prototyp `/schluessel/beispiel` samt `src/beispiel/schluessel.ts` entfernt; die Übersicht
|
||||||
|
(noch Prototyp, #026) verlinkt auf `/schluessel`.
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
# #013 Personen und Self-Service
|
# #013 Personen und Self-Service
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Personenliste/-detail
|
- [x] Personenliste/-detail
|
||||||
- [ ] Seite „Meine Schlüssel“
|
- [x] Seite „Meine Schlüssel“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#012
|
#012
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Personen pflegt, wer Schlüssel verwaltet** (Recht `schluesselVerwalten`, Admin und Operator) –
|
||||||
|
ein eigenes Recht wäre nur ein Alias. Lesen dürfen alle Rollen.
|
||||||
|
- **Archivieren statt Löschen:** die Schlüssel bleiben der Person zugeordnet (wer hatte bis wann
|
||||||
|
welchen Key?), die Kontoverknüpfung entfällt, damit der Self-Service nicht bei einer
|
||||||
|
archivierten Person landet. Die Notiz steht nicht im Protokoll, nur dass sie sich änderte.
|
||||||
|
- **Self-Service `/meine-schluessel`** (Recht `eigeneSchluesselHinterlegen`, alle Rollen,
|
||||||
|
Navigation „Meine Schlüssel“): beim ersten Aufruf wird die eigene Person angelegt oder
|
||||||
|
verknüpft – per `benutzerId`, sonst per E-Mail, aber **nur bei vom Anbieter bestätigter
|
||||||
|
Adresse** und genau einer freien, nicht archivierten Person. Dafür neue Spalte
|
||||||
|
`Benutzer.emailBestaetigt` (Migration `20261012103000_benutzer_email_bestaetigt`), gesetzt bei
|
||||||
|
jeder OIDC-Anmeldung. Ohne sie könnte jemand mit selbst gewählter Adresse die Keys einer fremden
|
||||||
|
Person sehen.
|
||||||
|
- Hinterlegte Keys: Kategorie `PERSON`, `selbstHinterlegt = true`, Protokoll
|
||||||
|
`schluessel.selbst_hinterlegt`. Hinweis in der Oberfläche: „Wirksam erst, wenn ein Operator
|
||||||
|
oder Admin den Schlüssel einem Host zuweist.“
|
||||||
|
- **Entfernen** nur eigene, selbst hinterlegte Keys ohne jede Verwendung (auch keine entfernte –
|
||||||
|
der Verlauf am Key bleibt). Das Löschen ist bedingt (`deleteMany` mit „keine Verwendungen“),
|
||||||
|
damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. Fremde IDs gelten als „nicht
|
||||||
|
vorhanden“. Tests: `tests/personen.test.ts`.
|
||||||
|
- Die Services nehmen den angemeldeten Benutzer entgegen und leiten die Person selbst ab; die
|
||||||
|
Actions reichen keine `personId` von außen durch (struktureller Test).
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
# #014 Private Key anzeigen und herunterladen
|
# #014 Private Key anzeigen und herunterladen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** sicherheit
|
**Labels:** sicherheit
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
|
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Einmal-Abholung
|
- [x] Einmal-Abholung
|
||||||
- [ ] Option „Private Key nach Ausrollen löschen“
|
- [x] Option „Private Key nach Ausrollen löschen“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Kein Caching, kein Logging
|
- [x] Kein Caching, kein Logging
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#012, #004
|
#012, #004
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
|
||||||
|
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
|
||||||
|
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
|
||||||
|
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
|
||||||
|
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
|
||||||
|
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
|
||||||
|
Fingerprint, nie mit Key.
|
||||||
|
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
|
||||||
|
`{ bereit: true }`, nie das Chiffrat.
|
||||||
|
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
|
||||||
|
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
|
||||||
|
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
|
||||||
|
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
|
||||||
|
ist eine Minute.
|
||||||
|
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
|
||||||
|
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
|
||||||
|
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
|
||||||
|
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
|
||||||
|
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
|
||||||
|
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
|
||||||
|
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
|
||||||
|
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
|
||||||
|
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
|
||||||
|
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
|
||||||
|
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
|
||||||
|
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
|
||||||
|
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
|
||||||
|
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
|
||||||
|
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
|
||||||
|
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
|
||||||
|
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
|
||||||
|
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
# #015 authorized_keys: Parser und Merge
|
# #015 authorized_keys: Parser und Merge
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, test
|
**Labels:** backend, test
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Optionen mit Anführungszeichen
|
- [x] Optionen mit Anführungszeichen
|
||||||
- [ ] CRLF, BOM, Kommentare, Duplikate
|
- [x] CRLF, BOM, Kommentare, Duplikate
|
||||||
- [ ] Block erzeugen/ersetzen, Rest bytegenau
|
- [x] Block erzeugen/ersetzen, Rest bytegenau
|
||||||
- [ ] Aussperrschutz Deployment-Key
|
- [x] Aussperrschutz Deployment-Key
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Umfangreiche Unit-Tests der Randfälle
|
- [x] Umfangreiche Unit-Tests der Randfälle
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Bytegenau:** Zeilen werden an `\n` getrennt, ein `\r` bleibt Teil der Zeile; BOM und fehlende letzte Zeilenschaltung bleiben erhalten. Einzige Änderung außerhalb des Blocks: Fehlt der Block und der letzten Zeile die Zeilenschaltung, bekommt sie eine – sonst stünde der Block-Anfang in derselben Zeile.
|
||||||
|
- **CRLF-Dateien** (überwiegend `\r\n`) bekommen auch im Block CRLF.
|
||||||
|
- **Beschädigter Block** (Anfang ohne Ende, mehrere Blöcke, Ende vor Anfang): nie automatisch schreiben, `BlockFehler` mit Bitte um Handarbeit.
|
||||||
|
- **Erkennung am Fingerprint**, nicht am Kommentar; der Typ im Blob muss zum Zeilentyp passen.
|
||||||
|
- **Optionen:** Parser wie sshd (Kommas in Anführungszeichen, `\"`). Für Benutzereingaben strenger: nur bekannte Optionen, keine Steuerzeichen.
|
||||||
|
- **Aussperrschutz:** Ein `pflicht`-Fingerprint (Deployment-Key), der im Block stand, darf nicht verschwinden.
|
||||||
|
- Diff per LCS, für die Anzeige verdichtet (je 2 Zeilen Kontext).
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #016 Public Key ausrollen mit Dry-Run und Backup
|
# #016 Public Key ausrollen mit Dry-Run und Backup
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Zuweisungen Schlüssel × Host/Gruppe × Benutzer; Dry-Run-Diff, Backup, atomares Schreiben, Rechte.
|
Zuweisungen Schlüssel × Host/Gruppe × Benutzer; Dry-Run-Diff, Backup, atomares Schreiben, Rechte.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Remote-Skript
|
- [x] Remote-Skript
|
||||||
- [ ] Optimistische Sperre über Datei-Hash
|
- [x] Optimistische Sperre über Datei-Hash
|
||||||
- [ ] Diff-Box im Schlüsseldetail
|
- [x] Diff-Box im Schlüsseldetail
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest Ausrollen/Entfernen
|
- [x] Integrationstest Ausrollen/Entfernen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#015, #010
|
#015, #010
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Zwei Schritte:** TROCKENLAUF hält je Ziel Diff, Hinweise und die Hashes vorher/nachher fest; AUSROLLEN führt genau diesen Plan aus und bricht je Ziel ab, wenn sich Datei **oder** Soll geändert hat. Ein Trockenlauf gilt 15 Minuten und nur einmal.
|
||||||
|
- **Auf dem Host** alles als Zielbenutzer (`runuser`), Hash dort erneut geprüft, Sicherung (letzte 5 bleiben), Temp-Datei + `mv`, 0700/0600.
|
||||||
|
- **Nur gültiges UTF-8** wird bearbeitet, sonst ginge „bytegenau“ verloren.
|
||||||
|
- **Soll** = direkte und Gruppen-Zuweisungen; der Deployment-Key gehört immer zum Soll des Anmeldebenutzers (Aussperrschutz aus #015).
|
||||||
|
- Gruppen-Verwendungen: Zustand je Verwendung, Fehlschlag auf einem Host gewinnt. Zustand je Host folgt mit dem Abgleich (#021).
|
||||||
|
- Oberfläche: Zuweisen auf der Schlüsselseite, Dry-Run je Schlüssel und je Host.
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #017 Private Key ausrollen und ~/.ssh/config
|
# #017 Private Key ausrollen und ~/.ssh/config
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Private Key auf Zielhost/-benutzer/-pfad, verwalteter Block in `~/.ssh/config`, `known_hosts`.
|
Private Key auf Zielhost/-benutzer/-pfad, verwalteter Block in `~/.ssh/config`, `known_hosts`.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Übertragung nur über stdin
|
- [x] Übertragung nur über stdin
|
||||||
- [ ] Warnung bei Passphrase
|
- [x] Warnung bei Passphrase
|
||||||
- [ ] nur Admin
|
- [x] nur Admin
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Dateien je Ziel:** Private Key (0600, keine Sicherungskopie), `.pub` (0644), verwalteter Block **am Anfang** von `~/.ssh/config` (ssh nimmt den ersten Treffer), verwalteter Block in `known_hosts` mit den vertrauten Host-Keys des Ziels unter dem HostName/Port aus der config.
|
||||||
|
- **Nie fremde Dateien überschreiben:** Liegt am Zielpfad schon etwas anderes, scheitert das Ziel im Trockenlauf.
|
||||||
|
- **Plan ohne Inhalt:** Der Trockenlauf zeigt beim Private Key nur die Aktion; weder Vorgang noch Protokoll noch Browser sehen den Schlüssel.
|
||||||
|
- **Nur Admins:** Zuweisen verlangt `privateKeyAusrollen`; der Trockenlauf nimmt Private-Key-Ziele nur für Admins auf.
|
||||||
|
- **Widerruf:** löschen oder umbenennen (`…sshkv-widerrufen.<Zeit>`), config- und known_hosts-Einträge verschwinden mit.
|
||||||
|
- Integrationstest: A meldet sich per Alias bei B an (StrictHostKeyChecking=yes). E2E im Browser für den ganzen Kern-Anwendungsfall.
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
# #018 Zielbenutzer anlegen und sudoers
|
# #018 Zielbenutzer anlegen und sudoers
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, sicherheit
|
**Labels:** backend, sicherheit
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
|
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Auswahl keine/bestimmte Befehle/voll
|
- [x] Auswahl keine/bestimmte Befehle/voll
|
||||||
- [ ] Entfernen beim Widerruf
|
- [x] Entfernen beim Widerruf
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **sudoers** entsteht als `/etc/sudoers.d/.sshkv.XXXX` (Punkt im Namen: sudo ignoriert die Datei), wird mit `visudo -cf` geprüft und erst dann nach `/etc/sudoers.d/sshkv-<benutzer>` verschoben. Kopfzeile kennzeichnet sshkv-Dateien; eine fremde Datei gleichen Namens wird nie überschrieben.
|
||||||
|
- **Befehle:** absoluter Pfad, keine Zeichen mit Sonderbedeutung in sudoers (`, : = \`, Wildcards) – die Liste soll wirken, wie sie aussieht.
|
||||||
|
- **Geschützte Konten** (root, sshkv, Systemkonten) verwaltet sshkv nie als Zielkonto.
|
||||||
|
- **Herkunft gemerkt:** `vonSshkvAngelegt` – nur dann löscht ein Entfernen den Benutzer samt Home, sonst nur die sudoers-Datei.
|
||||||
|
- Vorgänge `ZIELKONTO_ANLEGEN`/`ZIELKONTO_ENTFERNEN` (Migration `20261012110000`), Recht `zielkontenVerwalten` = Admin mit frischer Bestätigung. Oberfläche im Assistenten (#020) mit sudoers-Vorschau.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# #019 Vorlagen
|
# #019 Vorlagen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||||
@@ -8,12 +8,25 @@
|
|||||||
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Platzhalter `{{quellHostIp}}` usw.
|
- [x] Platzhalter `{{quellHostIp}}` usw.
|
||||||
|
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
|
||||||
|
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Vorlage füllt Assistent vor
|
- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#017, #018
|
#017, #018
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
|
||||||
|
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
|
||||||
|
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
|
||||||
|
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
|
||||||
|
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
|
||||||
|
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
|
||||||
|
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
|
||||||
|
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
|
||||||
|
wie eine zweite Regel aus.
|
||||||
|
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
|
||||||
|
zeigt deshalb keinen Archivieren-Knopf.
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #020 Host-zu-Host-Assistent und Verbindungstest
|
# #020 Host-zu-Host-Assistent und Verbindungstest
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
|
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Schrittanzeige
|
- [x] Schrittanzeige
|
||||||
- [ ] Zusammenfassung + Dry-Run
|
- [x] Zusammenfassung + Dry-Run
|
||||||
- [ ] `ssh -o BatchMode=yes … true` auf A
|
- [x] `ssh -o BatchMode=yes … true` auf A
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
- [x] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#019
|
#019
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Fünf Schritte:** Vorlage & Zweck, Quelle, Ziel (Zielbenutzer, optional anlegen mit sudo, Einschränkungen, Alias, HostName/Port aus Sicht von A, Fingerprint von B bestätigen), Zusammenfassung, Ausrollen (Dry-Run, anwenden, Verbindungstest).
|
||||||
|
- **Vorlage** füllt Zielbenutzer, Anlegen/sudo, Ablaufdatum, `from=`-Muster und Alias vor; Platzhalter werden mit A und B aufgelöst. Ist die Adresse von A keine IP, fragt der Assistent nach.
|
||||||
|
- **Wiederaufnehmbar:** Erledigte Schritte (Schlüssel, Zielkonto, Zuweisungen) werden beim „Fortsetzen“ übersprungen. Braucht das Zielkonto eine frische Bestätigung, geschieht sie in einem neuen Tab – die Eingaben bleiben stehen.
|
||||||
|
- **root als Ziel:** deutlicher Hinweis, dass ein Agent wie Claude Code dann uneingeschränkten Zugriff hätte.
|
||||||
|
- **Verbindungstest:** `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true` auf A als Zielbenutzer.
|
||||||
|
- E2E: Vorlage „Deployment-Zugang“ → `deploy` auf B angelegt → A meldet sich als deploy bei B an.
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
# #021 Abgleich und Drift-Erkennung
|
# #021 Abgleich und Drift-Erkennung
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Zeitplan konfigurierbar
|
- [x] Zeitplan konfigurierbar
|
||||||
- [ ] Befundarten
|
- [x] Befundarten
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei.
|
||||||
|
- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key).
|
||||||
|
- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009.
|
||||||
|
- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar.
|
||||||
|
- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen.
|
||||||
|
- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen.
|
||||||
|
- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt.
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
# #022 Import bestehender Schlüssel
|
# #022 Import bestehender Schlüssel
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat.
|
||||||
|
- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run).
|
||||||
|
- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an.
|
||||||
|
- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung.
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
# #023 Geführte Rotation
|
# #023 Geführte Rotation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar)
|
||||||
|
- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter
|
||||||
|
- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad
|
||||||
|
- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT`
|
||||||
|
- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“
|
||||||
|
- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`)
|
||||||
|
- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh <alias>` geht weiter
|
||||||
|
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#020
|
#020, #024
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests →
|
||||||
|
Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb
|
||||||
|
lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent.
|
||||||
|
- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der
|
||||||
|
Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel.
|
||||||
|
`vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich.
|
||||||
|
- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die
|
||||||
|
jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh <alias>` nach dem Ausrollen auf
|
||||||
|
die neue Datei, während die alte bis zum Widerruf daneben liegt.
|
||||||
|
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-rJJJJMMTT`, bei Kollision mit laufender Nummer –
|
||||||
|
auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände).
|
||||||
|
- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit
|
||||||
|
Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das).
|
||||||
|
Ohne Private-Zuweisung mit Alias entfällt der Test.
|
||||||
|
- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver
|
||||||
|
Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`.
|
||||||
|
- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht
|
||||||
|
übernommen (sshkv erzeugt ohne).
|
||||||
|
- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`.
|
||||||
|
Deployment-Key: eigene Rotation (später).
|
||||||
|
|||||||
@@ -1,19 +1,49 @@
|
|||||||
# #024 Widerruf und Massenwiderruf
|
# #024 Widerruf und Massenwiderruf
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui, sicherheit
|
**Labels:** backend, ui, sicherheit
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
|
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet
|
||||||
|
- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN`
|
||||||
|
- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes)
|
||||||
|
- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel
|
||||||
|
- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`)
|
||||||
|
- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`)
|
||||||
|
- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png`
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#018
|
#018
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration
|
||||||
|
`20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr
|
||||||
|
offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger
|
||||||
|
existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele.
|
||||||
|
- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional
|
||||||
|
Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen.
|
||||||
|
Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt
|
||||||
|
ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden
|
||||||
|
automatisch und meldet, was noch fehlt.
|
||||||
|
- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel
|
||||||
|
„Zustand übernehmen“ an (sonst bliebe der Widerruf hängen).
|
||||||
|
- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als
|
||||||
|
entfernt; der Dialog sagt das, das Protokoll nennt die Hosts.
|
||||||
|
- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto
|
||||||
|
auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf
|
||||||
|
befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel).
|
||||||
|
- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin);
|
||||||
|
mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer
|
||||||
|
Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht
|
||||||
|
mehr zuweisen.
|
||||||
|
- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die
|
||||||
|
Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch.
|
||||||
|
- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als
|
||||||
|
`?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung.
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
# #025 Zuweisungsmatrix
|
# #025 Zuweisungsmatrix
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Matrix Schlüssel × Host.
|
Matrix Schlüssel × Host.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen
|
||||||
|
- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono
|
||||||
|
- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“
|
||||||
|
- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel
|
||||||
|
- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“
|
||||||
|
- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv`
|
||||||
|
- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts`
|
||||||
|
- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts`
|
||||||
|
- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar
|
||||||
|
- [x] Zustand nie nur über Farbe
|
||||||
|
- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das)
|
||||||
|
- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen
|
||||||
|
- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft
|
||||||
|
- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird
|
||||||
|
weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`).
|
||||||
|
- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die
|
||||||
|
Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt
|
||||||
|
einer leeren Matrix.
|
||||||
|
- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied –
|
||||||
|
dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`.
|
||||||
|
- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron).
|
||||||
|
- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad
|
||||||
|
als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite.
|
||||||
|
- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst).
|
||||||
|
- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das.
|
||||||
|
- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine
|
||||||
|
Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sshkv",
|
"name": "sshkv",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014).
|
||||||
|
--
|
||||||
|
-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den
|
||||||
|
-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit
|
||||||
|
-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt.
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN';
|
||||||
|
|
||||||
|
-- Der Slug landet in Dateinamen (<slug>.pub, ~/.ssh/id_<slug>) und Kommentaren. Was hier
|
||||||
|
-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript.
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK (
|
||||||
|
"slug" ~ '^[a-z0-9-]{1,64}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014).
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK (
|
||||||
|
"privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Self-Service „Meine Schluessel“ (#013): ein Konto wird per E-Mail nur dann mit einer
|
||||||
|
-- vorhandenen Person verknuepft, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Sonst
|
||||||
|
-- koennte jemand mit selbst gewaehlter Adresse die Schluessel einer fremden Person sehen.
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "emailBestaetigt" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN),
|
||||||
|
-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT.
|
||||||
|
ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -104,6 +104,16 @@ enum VorgangsArt {
|
|||||||
ROTATION
|
ROTATION
|
||||||
WIDERRUF
|
WIDERRUF
|
||||||
PRIVATE_KEY_ABHOLEN
|
PRIVATE_KEY_ABHOLEN
|
||||||
|
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_ERZEUGEN
|
||||||
|
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_IMPORTIEREN
|
||||||
|
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||||
|
PRIVATE_KEY_LOESCHEN
|
||||||
|
/// Zielbenutzer anlegen und sudoers setzen (#018).
|
||||||
|
ZIELKONTO_ANLEGEN
|
||||||
|
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||||
|
ZIELKONTO_ENTFERNEN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
@@ -129,6 +139,9 @@ model Benutzer {
|
|||||||
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
||||||
oidcSub String? @unique
|
oidcSub String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
|
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
||||||
|
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
||||||
|
emailBestaetigt Boolean @default(false)
|
||||||
/// Anmeldename des Notfall-Admins.
|
/// Anmeldename des Notfall-Admins.
|
||||||
anmeldename String? @unique
|
anmeldename String? @unique
|
||||||
name String
|
name String
|
||||||
@@ -289,6 +302,9 @@ model Schluessel {
|
|||||||
tags String[]
|
tags String[]
|
||||||
ablaufAm DateTime?
|
ablaufAm DateTime?
|
||||||
zuletztVerwendetAm DateTime?
|
zuletztVerwendetAm DateTime?
|
||||||
|
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
|
||||||
|
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||||||
|
widerrufEingeleitetAm DateTime?
|
||||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||||||
vorgaengerId String? @unique
|
vorgaengerId String? @unique
|
||||||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||||||
@@ -320,6 +336,9 @@ model PublicVerwendung {
|
|||||||
zielbenutzer String
|
zielbenutzer String
|
||||||
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
||||||
optionen String?
|
optionen String?
|
||||||
|
/// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene
|
||||||
|
/// Keys (#022), sonst fasst sshkv fremde Zeilen nie an.
|
||||||
|
ausserhalbEntfernen Boolean @default(false)
|
||||||
zustand VerwendungsZustand @default(GEPLANT)
|
zustand VerwendungsZustand @default(GEPLANT)
|
||||||
zuletztAngewendetAm DateTime?
|
zuletztAngewendetAm DateTime?
|
||||||
angelegtVonId String?
|
angelegtVonId String?
|
||||||
|
|||||||
@@ -124,10 +124,99 @@ async function demoDaten(): Promise<void> {
|
|||||||
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
|
||||||
|
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
|
||||||
|
* verworfen – es sind Attrappen, die nirgends Zugang geben.
|
||||||
|
*/
|
||||||
|
const DEMO_PERSONEN = [
|
||||||
|
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
|
||||||
|
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
|
||||||
|
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DEMO_SCHLUESSEL: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
kategorie: "PERSON" | "DIENST";
|
||||||
|
person?: string;
|
||||||
|
tags: string[];
|
||||||
|
ablaufInTagen?: number;
|
||||||
|
kommentar: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: "Laptop Florian",
|
||||||
|
slug: "laptop-florian",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Florian Wilde",
|
||||||
|
tags: ["laptop"],
|
||||||
|
kommentar: "florian@laptop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wartungszugang Netzwerk GmbH",
|
||||||
|
slug: "wartung-netzwerk-gmbh",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Anna Berger",
|
||||||
|
tags: ["dienstleister"],
|
||||||
|
ablaufInTagen: 18,
|
||||||
|
kommentar: "anna@netzwerk-gmbh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Workstation Mehmet",
|
||||||
|
slug: "workstation-mehmet",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Mehmet Yılmaz",
|
||||||
|
tags: [],
|
||||||
|
ablaufInTagen: 200,
|
||||||
|
kommentar: "mehmet@ws-07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monitoring zabbix → alle Hosts",
|
||||||
|
slug: "monitoring-zabbix",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
tags: ["monitoring"],
|
||||||
|
kommentar: "zabbix@srv-mon01",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function demoSchluessel(): Promise<void> {
|
||||||
|
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
|
||||||
|
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
|
||||||
|
const personen = new Map<string, string>();
|
||||||
|
for (const p of DEMO_PERSONEN) {
|
||||||
|
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
|
||||||
|
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
|
||||||
|
personen.set(p.name, person.id);
|
||||||
|
}
|
||||||
|
for (const s of DEMO_SCHLUESSEL) {
|
||||||
|
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
publicKey: paar.publicKeyZeile,
|
||||||
|
fingerprint: paar.fingerprint,
|
||||||
|
kommentar: s.kommentar,
|
||||||
|
tags: s.tags,
|
||||||
|
personId: s.person ? (personen.get(s.person) ?? null) : null,
|
||||||
|
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await notfallAdmin();
|
await notfallAdmin();
|
||||||
await vorlagen();
|
await vorlagen();
|
||||||
await demoDaten();
|
await demoDaten();
|
||||||
|
await demoSchluessel();
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker.
|
||||||
|
*
|
||||||
|
* GITEA_URL=https://git.wilde-server.de \
|
||||||
|
* GITEA_REPO=florian.wilde/sshkv \
|
||||||
|
* GITEA_TOKEN=... \
|
||||||
|
* npm run issues:sync
|
||||||
|
*
|
||||||
|
* Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde.
|
||||||
|
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
interface IssueDatei {
|
||||||
|
nummer: number;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
meilenstein: string | null;
|
||||||
|
labels: string[];
|
||||||
|
koerper: string;
|
||||||
|
dateiname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Farben der Labels, angelehnt an die Design-Tokens (#030). */
|
||||||
|
const LABEL_FARBEN: Record<string, string> = {
|
||||||
|
backend: "#6aa8ff",
|
||||||
|
ui: "#f0b43c",
|
||||||
|
datenbank: "#3cc9a7",
|
||||||
|
sicherheit: "#f2714b",
|
||||||
|
test: "#3cc9a7",
|
||||||
|
docs: "#8b96a3",
|
||||||
|
infrastruktur: "#2a323b",
|
||||||
|
chore: "#8b96a3",
|
||||||
|
};
|
||||||
|
|
||||||
|
function feld(inhalt: string, name: string): string | null {
|
||||||
|
const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt);
|
||||||
|
return treffer ? (treffer[1] ?? "").trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateienLesen(verzeichnis: string): Promise<IssueDatei[]> {
|
||||||
|
const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
|
||||||
|
|
||||||
|
const issues: IssueDatei[] = [];
|
||||||
|
for (const dateiname of namen) {
|
||||||
|
const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8");
|
||||||
|
const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt);
|
||||||
|
if (!kopf) {
|
||||||
|
console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten).
|
||||||
|
const körperStart = inhalt.indexOf("## Ziel");
|
||||||
|
issues.push({
|
||||||
|
nummer: Number(kopf[1]),
|
||||||
|
titel: (kopf[2] ?? "").trim(),
|
||||||
|
status: feld(inhalt, "Status") ?? "offen",
|
||||||
|
meilenstein: feld(inhalt, "Meilenstein"),
|
||||||
|
labels: (feld(inhalt, "Labels") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((label) => label.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt,
|
||||||
|
dateiname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gitea {
|
||||||
|
constructor(
|
||||||
|
private readonly basis: string,
|
||||||
|
private readonly repo: string,
|
||||||
|
private readonly token: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
||||||
|
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
|
||||||
|
...optionen,
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${this.token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...optionen.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`);
|
||||||
|
}
|
||||||
|
return (await antwort.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
labels() {
|
||||||
|
return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100");
|
||||||
|
}
|
||||||
|
|
||||||
|
labelAnlegen(name: string) {
|
||||||
|
return this.anfrage<{ id: number; name: string }>("/labels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
meilensteine() {
|
||||||
|
return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100");
|
||||||
|
}
|
||||||
|
|
||||||
|
meilensteinAnlegen(title: string) {
|
||||||
|
return this.anfrage<{ id: number; title: string }>("/milestones", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
issues() {
|
||||||
|
return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200");
|
||||||
|
}
|
||||||
|
|
||||||
|
issueAnlegen(daten: Record<string, unknown>) {
|
||||||
|
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
|
||||||
|
}
|
||||||
|
|
||||||
|
issueAendern(nummer: number, daten: Record<string, unknown>) {
|
||||||
|
return this.anfrage<{ number: number }>(`/issues/${nummer}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(daten),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
|
||||||
|
* Beispiel: https://git.example.de/benutzer/sshkv.git → { basis, repo }
|
||||||
|
*/
|
||||||
|
function ausRemote(): { basis: string; repo: string } | null {
|
||||||
|
try {
|
||||||
|
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
|
||||||
|
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
|
||||||
|
if (!treffer) return null;
|
||||||
|
return { basis: `https://${treffer[1]}`, repo: treffer[2] ?? "" };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
|
||||||
|
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
|
||||||
|
*/
|
||||||
|
function tokenAusGitCredentials(basis: string): string | null {
|
||||||
|
const host = new URL(basis).host;
|
||||||
|
|
||||||
|
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
|
||||||
|
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
|
||||||
|
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
|
||||||
|
try {
|
||||||
|
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
|
||||||
|
input: `protocol=https\nhost=${host}\n\n`,
|
||||||
|
encoding: "utf8",
|
||||||
|
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const passwort = /^password=(.*)$/m.exec(antwort);
|
||||||
|
if (passwort?.[1]) return passwort[1];
|
||||||
|
} catch {
|
||||||
|
// Nächste Möglichkeit versuchen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
|
||||||
|
function giteaTitel(issue: IssueDatei): string {
|
||||||
|
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const trockenlauf = process.argv.includes("--dry-run");
|
||||||
|
const remote = ausRemote();
|
||||||
|
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
|
||||||
|
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
|
||||||
|
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
|
||||||
|
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
|
||||||
|
|
||||||
|
const verzeichnis = path.join(process.cwd(), "docs", "issues");
|
||||||
|
const issues = await dateienLesen(verzeichnis);
|
||||||
|
console.log(`${issues.length} Issue-Dateien gelesen.`);
|
||||||
|
|
||||||
|
if (trockenlauf || !basis || !repo || !token) {
|
||||||
|
if (!trockenlauf) {
|
||||||
|
console.log(
|
||||||
|
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
|
||||||
|
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const issue of issues) {
|
||||||
|
console.log(
|
||||||
|
` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${
|
||||||
|
issue.labels.join(", ") || "–"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
|
||||||
|
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
|
||||||
|
const gitea = new Gitea(basis, repo, token);
|
||||||
|
|
||||||
|
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
|
||||||
|
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
|
||||||
|
for (const name of benoetigteLabels) {
|
||||||
|
if (!vorhandeneLabels.has(name)) {
|
||||||
|
const neu = await gitea.labelAnlegen(name);
|
||||||
|
vorhandeneLabels.set(name, neu.id);
|
||||||
|
console.log(`Label angelegt: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id]));
|
||||||
|
const benoetigteMeilensteine = [
|
||||||
|
...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))),
|
||||||
|
].sort();
|
||||||
|
for (const titel of benoetigteMeilensteine) {
|
||||||
|
if (!vorhandeneMeilensteine.has(titel)) {
|
||||||
|
const neu = await gitea.meilensteinAnlegen(titel);
|
||||||
|
vorhandeneMeilensteine.set(titel, neu.id);
|
||||||
|
console.log(`Meilenstein angelegt: ${titel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i]));
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const titel = giteaTitel(issue);
|
||||||
|
const daten = {
|
||||||
|
title: titel,
|
||||||
|
body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`,
|
||||||
|
labels: issue.labels
|
||||||
|
.map((name) => vorhandeneLabels.get(name))
|
||||||
|
.filter((id): id is number => Boolean(id)),
|
||||||
|
milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vorhanden = vorhandeneIssues.get(titel);
|
||||||
|
const zustand = issue.status.startsWith("erledigt") ? "closed" : "open";
|
||||||
|
|
||||||
|
if (vorhanden) {
|
||||||
|
await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand });
|
||||||
|
console.log(`Aktualisiert: ${titel} (${zustand})`);
|
||||||
|
} else {
|
||||||
|
const neu = await gitea.issueAnlegen(daten);
|
||||||
|
if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" });
|
||||||
|
console.log(`Angelegt: ${titel} (${zustand})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nAbgleich abgeschlossen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((fehler) => {
|
||||||
|
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -27,9 +27,28 @@ const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/
|
|||||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
||||||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||||||
{ name: "uebersicht", pfad: "/" },
|
{ name: "uebersicht", pfad: "/" },
|
||||||
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
|
{ name: "schluessel", pfad: "/schluessel" },
|
||||||
|
{ name: "schluessel-neu", pfad: "/schluessel/neu" },
|
||||||
|
// Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012).
|
||||||
|
{
|
||||||
|
name: "schluessel-detail",
|
||||||
|
pfad: "/schluessel?q=wartung",
|
||||||
|
folgen: 'main table a[href^="/schluessel/"]',
|
||||||
|
},
|
||||||
|
{ name: "personen", pfad: "/personen" },
|
||||||
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
|
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
|
||||||
|
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
|
||||||
|
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
|
||||||
|
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||||
|
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||||
|
{
|
||||||
|
name: "vorlage-bearbeiten",
|
||||||
|
pfad: "/vorlagen",
|
||||||
|
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Bestätigt" };
|
||||||
|
|
||||||
|
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
|
||||||
|
export default async function BestaetigtSeite() {
|
||||||
|
await sitzungErforderlich();
|
||||||
|
return (
|
||||||
|
<Leerzustand
|
||||||
|
titel="Anmeldung bestätigt"
|
||||||
|
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Assistent } from "@/components/assistent/Assistent";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { assistentDaten } from "@/server/queries/assistent";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
|
||||||
|
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
|
||||||
|
*/
|
||||||
|
export default async function AssistentSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
|
||||||
|
const daten = await assistentDaten();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
|
||||||
|
titel="Host-zu-Host-Assistent"
|
||||||
|
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
|
||||||
|
/>
|
||||||
|
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||||
|
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { istZustand } from "@/server/queries/abgleich";
|
||||||
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
|
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||||
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
||||||
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
|
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
|
||||||
@@ -95,6 +100,9 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
const archiviert = host.archiviertAm !== null;
|
const archiviert = host.archiviertAm !== null;
|
||||||
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
|
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
|
||||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||||
|
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||||
|
const verwendungen = await verwendungenAufHost(host.id);
|
||||||
|
const ist = await istZustand(host.id);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -217,7 +225,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
|
|
||||||
<Karte
|
<Karte
|
||||||
titel="Schlüssel auf diesem Host"
|
titel="Schlüssel auf diesem Host"
|
||||||
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
|
untertitel="Public Keys in authorized_keys und Private Keys, die sshkv hier verwaltet"
|
||||||
>
|
>
|
||||||
{host.zielkonten.length > 0 && (
|
{host.zielkonten.length > 0 && (
|
||||||
<p className="text-muted mb-4 text-[14px]">
|
<p className="text-muted mb-4 text-[14px]">
|
||||||
@@ -230,13 +238,76 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
|
||||||
<Leerzustand
|
<Leerzustand
|
||||||
icon={<KeyRound className="size-5" />}
|
icon={<KeyRound className="size-5" />}
|
||||||
titel="Folgt mit #016"
|
titel="Noch keine Schlüssel zugewiesen"
|
||||||
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)."
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{verwendungen.oeffentlich.map((v) => (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/schluessel/${v.schluessel.id}`}
|
||||||
|
className="text-text hover:text-accent font-medium"
|
||||||
|
>
|
||||||
|
{v.schluessel.name}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||||
|
Public · {v.zielbenutzer}@{host.name}
|
||||||
|
{v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""}
|
||||||
|
{v.optionen ? ` · ${v.optionen}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<VerwendungsZustand zustand={v.zustand} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{verwendungen.privat.map((v) => (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/schluessel/${v.schluessel.id}`}
|
||||||
|
className="text-text hover:text-accent font-medium"
|
||||||
|
>
|
||||||
|
{v.schluessel.name}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||||
|
Private · {v.zielbenutzer}@{host.name}:{v.pfad}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<VerwendungsZustand zustand={v.zustand} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && (
|
||||||
|
<div className="border-border mt-5 border-t pt-5">
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={{ hostId: host.id }}
|
||||||
|
offen={verwendungen.oeffentlich.filter((v) => v.zustand !== "AUSGEROLLT").length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
{host.bootstrapAm && (
|
||||||
|
<AbgleichKarten
|
||||||
|
hostId={host.id}
|
||||||
|
zustand={ist}
|
||||||
|
darfAbgleichen={verwalten}
|
||||||
|
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
|
||||||
|
darfVerwalten={darfAusrollen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{verwalten && (
|
{verwalten && (
|
||||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { KeyEntfernen, KeyHinterlegen } from "@/components/personen/MeineSchluessel";
|
||||||
|
import { StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { eigeneSchluessel } from "@/server/services/meine-schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Meine Schlüssel" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-Service (#013): eigene Public Keys hinterlegen und – solange nirgends zugewiesen –
|
||||||
|
* wieder entfernen. Beim ersten Aufruf entsteht die eigene Person (oder wird verknuepft).
|
||||||
|
*/
|
||||||
|
export default async function MeineSchluesselSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("eigeneSchluesselHinterlegen", "/meine-schluessel");
|
||||||
|
const { schluessel } = await eigeneSchluessel(benutzer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-4xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Meine Schlüssel"
|
||||||
|
untertitel="Ihre Public Keys für die Anmeldung an Servern. Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte titel="Hinterlegt" untertitel={`${schluessel.length} Schlüssel`}>
|
||||||
|
{schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Hinterlegen Sie unten Ihren Public Key. Danach kann ein Operator oder Admin ihn den Servern zuweisen, auf die Sie Zugriff brauchen."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{schluessel.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="border-border flex flex-col gap-2 border-b py-4 first:pt-0 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text text-[14.5px] font-medium">{s.name}</p>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={s.fingerprint}
|
||||||
|
beschriftung="Fingerprint"
|
||||||
|
umbrechen
|
||||||
|
className="text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.verwendungen > 0 ? (
|
||||||
|
<Badge variante="ok">
|
||||||
|
{s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variante="messing">noch nicht zugewiesen</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-muted text-[13px]">seit {datum(s.angelegtAm)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.selbstHinterlegt && s.verwendungen === 0 && (
|
||||||
|
<KeyEntfernen schluesselId={s.id} name={s.name} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<KeyHinterlegen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
|
import { ArrowRight, Plus, Route } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
letzterAbgleich,
|
letzterAbgleich,
|
||||||
type AktionsArt,
|
type AktionsArt,
|
||||||
} from "@/beispiel/uebersicht";
|
} from "@/beispiel/uebersicht";
|
||||||
|
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Karte } from "@/components/ui/Karte";
|
import { Karte } from "@/components/ui/Karte";
|
||||||
@@ -52,8 +53,8 @@ export default async function Uebersicht() {
|
|||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
<AlleAbgleichenKnopf />
|
||||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||||
Host-zu-Host-Assistent
|
Host-zu-Host-Assistent
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -136,7 +137,7 @@ export default async function Uebersicht() {
|
|||||||
return (
|
return (
|
||||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href="/schluessel/beispiel"
|
href="/schluessel"
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PersonFormular } from "@/components/personen/PersonFormular";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { kontenAuswahl, personDetail } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person bearbeiten" };
|
||||||
|
|
||||||
|
export default async function PersonBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", `/personen/${id}/bearbeiten`);
|
||||||
|
const [person, konten] = await Promise.all([personDetail(id), kontenAuswahl(id)]);
|
||||||
|
if (!person || person.archiviertAm) notFound();
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Personen", href: "/personen" },
|
||||||
|
{ titel: person.name, href: `/personen/${person.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Person bearbeiten"
|
||||||
|
/>
|
||||||
|
<PersonFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
email: person.email ?? "",
|
||||||
|
organisation: person.organisation ?? "",
|
||||||
|
notiz: person.notiz ?? "",
|
||||||
|
benutzerId: person.benutzerId ?? "",
|
||||||
|
}}
|
||||||
|
konten={konten}
|
||||||
|
abbrechenZiel={`/personen/${person.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { KeyRound, Pencil, Plus } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { PersonArchivieren } from "@/components/personen/PersonFormular";
|
||||||
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { fingerprintKurz } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personDetail } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person" };
|
||||||
|
|
||||||
|
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persondetail (#013) mit ihren Schlüsseln. */
|
||||||
|
export default async function PersonSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", `/personen/${id}`);
|
||||||
|
const person = await personDetail(id);
|
||||||
|
if (!person) notFound();
|
||||||
|
const archiviert = person.archiviertAm !== null;
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten") && !archiviert;
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: person.name }]} />}
|
||||||
|
titel={person.name}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{archiviert && person.archiviertAm && (
|
||||||
|
<Badge variante="gefahr">archiviert am {datum(person.archiviertAm)}</Badge>
|
||||||
|
)}
|
||||||
|
{person.organisation && <Badge>{person.organisation}</Badge>}
|
||||||
|
{person.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/personen/${person.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Info titel="E-Mail">{person.email ?? <span className="text-muted">–</span>}</Info>
|
||||||
|
<Info titel="Konto">
|
||||||
|
{person.benutzer ? (
|
||||||
|
<>
|
||||||
|
{person.benutzer.name}
|
||||||
|
{person.benutzer.email && (
|
||||||
|
<span className="text-muted block text-[13px]">{person.benutzer.email}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nicht verknüpft</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Angelegt">{datum(person.angelegtAm)}</Info>
|
||||||
|
</dl>
|
||||||
|
{person.notiz && (
|
||||||
|
<p className="border-border text-muted mt-5 border-t pt-4 text-[14px] whitespace-pre-line">
|
||||||
|
{person.notiz}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel={`${person.schluessel.length} Schlüssel dieser Person`}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/schluessel/neu?person=${person.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
Public Key hinterlegen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{person.schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Die Person kann ihre Public Keys auch selbst unter „Meine Schlüssel“ hinterlegen, sobald ihr Konto verknüpft ist."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{person.schluessel.map((s) => (
|
||||||
|
<li key={s.id} className="border-border border-b py-3 first:pt-0 last:border-b-0 last:pb-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent text-[14.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
{s.verwendungen === 0
|
||||||
|
? "· noch keinem Host zugewiesen"
|
||||||
|
: `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten && (
|
||||||
|
<Karte titel="Archivieren" untertitel="Person stilllegen, ohne die Historie zu verlieren">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich
|
||||||
|
weiterhin widerrufen.
|
||||||
|
</p>
|
||||||
|
<PersonArchivieren personId={person.id} name={person.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LEERE_PERSON, PersonFormular } from "@/components/personen/PersonFormular";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { kontenAuswahl } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person anlegen" };
|
||||||
|
|
||||||
|
export default async function PersonNeu() {
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", "/personen/neu");
|
||||||
|
const konten = await kontenAuswahl();
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: "Neue Person" }]} />}
|
||||||
|
titel="Person anlegen"
|
||||||
|
untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören."
|
||||||
|
/>
|
||||||
|
<PersonFormular vorgabe={LEERE_PERSON} konten={konten} abbrechenZiel="/personen" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,137 @@
|
|||||||
|
import { Plus, Search, Users } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenListe } from "@/server/queries/personen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Personen" };
|
export const metadata: Metadata = { title: "Personen" };
|
||||||
|
|
||||||
export default async function Personen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Anzahl({ n }: { n: number }) {
|
||||||
|
return n === 0 ? <span className="text-muted">keine</span> : <>{n}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personenliste (#013): wem welche Schlüssel gehören. */
|
||||||
|
export default async function Personen({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich("lesen", "/personen");
|
||||||
|
const q = einzeln((await searchParams)["q"]);
|
||||||
|
const personen = await personenListe({ q });
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/personen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Person anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Personen"
|
||||||
|
untertitel={q ? `${personen.length} Treffer` : "Wem welche Schlüssel gehören"}
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
<form method="get" role="search" className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Name, E-Mail oder Organisation"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
{q && (
|
||||||
|
<Button href="/personen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{personen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Users className="size-5" />}
|
||||||
|
titel={q ? "Keine Treffer" : "Noch keine Personen"}
|
||||||
|
text={
|
||||||
|
q
|
||||||
|
? "Keine Person passt zur Suche."
|
||||||
|
: "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt."
|
||||||
|
}
|
||||||
|
aktion={q ? <Button href="/personen">Suche zurücksetzen</Button> : anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{personen.map((p) => (
|
||||||
|
<li key={p.id} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-1 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text text-[14.5px] font-medium">{p.name}</span>
|
||||||
|
{p.email && <span className="text-muted truncate text-[13px]">{p.email}</span>}
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{p.organisation && <Badge>{p.organisation}</Badge>}
|
||||||
|
{p.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Aktive Schlüssel: <Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Personen"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "E-Mail" },
|
||||||
|
{ titel: "Organisation" },
|
||||||
|
{ titel: "Konto" },
|
||||||
|
{ titel: "Aktive Schlüssel" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<TabellenZeile key={p.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="text-muted">{p.email ?? "–"}</TabellenZelle>
|
||||||
|
<TabellenZelle>{p.organisation ?? <span className="text-muted">–</span>}</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
{p.benutzer ? p.benutzer.name : <span className="text-muted">–</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
|
import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */
|
||||||
|
export function PrivateAusrollenAktion({
|
||||||
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
slug,
|
||||||
|
passphraseGeschuetzt = false,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
slug?: string;
|
||||||
|
passphraseGeschuetzt?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl || !slug) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Private Key ausrollen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PrivateZuweisenFormular
|
||||||
|
schluesselId={schluesselId}
|
||||||
|
slug={slug}
|
||||||
|
auswahl={auswahl}
|
||||||
|
passphraseGeschuetzt={passphraseGeschuetzt}
|
||||||
|
beiErfolg={schliessen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import {
|
||||||
|
abholungAnfordernAktion,
|
||||||
|
privateKeyAnzeigenAktion,
|
||||||
|
privateKeyLoeschenAktion,
|
||||||
|
type PrivateKeyAntwort,
|
||||||
|
} from "@/server/actions/privatekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
|
||||||
|
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
|
||||||
|
* damit der Dialog gleich wieder offen ist.
|
||||||
|
*
|
||||||
|
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
|
||||||
|
*/
|
||||||
|
const DIALOG_KLASSEN =
|
||||||
|
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
|
||||||
|
|
||||||
|
function useBestaetigungUmleitung() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
return (antwort: PrivateKeyAntwort): boolean => {
|
||||||
|
if (!antwort.bestaetigen) return false;
|
||||||
|
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const download = useRef<HTMLFormElement>(null);
|
||||||
|
const suche = useSearchParams();
|
||||||
|
const umleiten = useBestaetigungUmleitung();
|
||||||
|
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
|
||||||
|
const [vorgangId, setVorgangId] = useState<string>();
|
||||||
|
const [privateKey, setPrivateKey] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||||
|
const abgeholt = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (suche.get("privatekey") === "1") dialog.current?.showModal();
|
||||||
|
}, [suche]);
|
||||||
|
|
||||||
|
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
|
||||||
|
abgeholt.current = vorgangId;
|
||||||
|
if (zweck === "herunterladen") {
|
||||||
|
download.current?.submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setPrivateKey(antwort.privateKey);
|
||||||
|
});
|
||||||
|
}, [vorgangId, stand?.status, zweck, umleiten]);
|
||||||
|
|
||||||
|
function anfordern(art: "anzeigen" | "herunterladen") {
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
setPrivateKey(undefined);
|
||||||
|
setZweck(art);
|
||||||
|
const antwort = await abholungAnfordernAktion(schluesselId, art);
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setVorgangId(antwort.vorgangId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliessen() {
|
||||||
|
// Nichts vom Key bleibt im Zustand stehen.
|
||||||
|
setPrivateKey(undefined);
|
||||||
|
setVorgangId(undefined);
|
||||||
|
setFehler(undefined);
|
||||||
|
dialog.current?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||||
|
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||||
|
const downloadGestartet =
|
||||||
|
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||||
|
>
|
||||||
|
anzeigen (Re-Auth)
|
||||||
|
</button>
|
||||||
|
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
|
||||||
|
Private Key von „{name}“
|
||||||
|
</h2>
|
||||||
|
{privateKey ? (
|
||||||
|
<>
|
||||||
|
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
|
||||||
|
<p className="text-accent flex items-start gap-2 text-[14px]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : downloadGestartet ? (
|
||||||
|
<p role="status" className="text-ok text-[14.5px]">
|
||||||
|
Download gestartet. Der Private Key wird nicht erneut angeboten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
|
||||||
|
jeder Download steht im Audit-Log.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Eye className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => anfordern("anzeigen")}
|
||||||
|
>
|
||||||
|
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<Download className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => anfordern("herunterladen")}
|
||||||
|
>
|
||||||
|
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={schliessen}>Schließen</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
|
||||||
|
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const umleiten = useBestaetigungUmleitung();
|
||||||
|
const [verstanden, setVerstanden] = useState(false);
|
||||||
|
const [vorgangId, setVorgangId] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stand?.status === "ERLEDIGT") {
|
||||||
|
dialog.current?.close();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [stand?.status, router]);
|
||||||
|
|
||||||
|
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||||
|
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Private Key löschen
|
||||||
|
</Button>
|
||||||
|
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
|
||||||
|
Private Key von „{name}“ aus SSHKeyVault löschen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
|
||||||
|
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
|
||||||
|
auf weitere Hosts ausrollen.
|
||||||
|
</p>
|
||||||
|
<Ankreuzfeld
|
||||||
|
checked={verstanden}
|
||||||
|
onChange={(e) => setVerstanden(e.target.checked)}
|
||||||
|
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
|
||||||
|
/>
|
||||||
|
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
disabled={!verstanden || laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await privateKeyLoeschenAktion(schluesselId);
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setVorgangId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Lösche …" : "Endgültig löschen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
|
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */
|
||||||
|
export function PublicAusrollenAktion({
|
||||||
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Public Key zuweisen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PublicZuweisenFormular schluesselId={schluesselId} auswahl={auswahl} beiErfolg={schliessen} />
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
|
||||||
|
|
||||||
|
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
|
||||||
|
const [s, personen, vorlagen] = await Promise.all([
|
||||||
|
schluesselDetail(id),
|
||||||
|
personenAuswahl(),
|
||||||
|
vorlagenAuswahl(),
|
||||||
|
]);
|
||||||
|
if (!s) notFound();
|
||||||
|
|
||||||
|
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
|
||||||
|
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
|
||||||
|
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
|
||||||
|
if (s.person && !personListe.some((p) => p.id === s.person?.id))
|
||||||
|
personListe.push({ ...s.person, zusatz: null });
|
||||||
|
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
|
||||||
|
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Schlüssel", href: "/schluessel" },
|
||||||
|
{ titel: s.name, href: `/schluessel/${s.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Schlüssel bearbeiten"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SchluesselBearbeiten
|
||||||
|
werte={{
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
tags: s.tags,
|
||||||
|
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
|
||||||
|
personId: s.personId ?? "",
|
||||||
|
vorlageId: s.vorlageId ?? "",
|
||||||
|
status: s.status,
|
||||||
|
statusAenderbar:
|
||||||
|
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
|
||||||
|
}}
|
||||||
|
personen={personListe}
|
||||||
|
vorlagen={vorlagenListe}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||||
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||||
|
import { ablaufZustand } from "@/lib/schluessel";
|
||||||
|
import { rotationGesperrt } from "@/server/services/rotation";
|
||||||
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
|
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||||
|
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||||||
|
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||||||
|
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||||||
|
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||||||
|
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||||||
|
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||||||
|
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eintrag = {
|
||||||
|
id: string;
|
||||||
|
ziel: string;
|
||||||
|
href?: string;
|
||||||
|
pfad: string;
|
||||||
|
zusatz?: string;
|
||||||
|
zustand: string;
|
||||||
|
aktionen?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AusrollKarte({
|
||||||
|
titel,
|
||||||
|
untertitel,
|
||||||
|
eintraege,
|
||||||
|
leer,
|
||||||
|
aktion,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
untertitel: string;
|
||||||
|
eintraege: Eintrag[];
|
||||||
|
leer: string;
|
||||||
|
aktion: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||||||
|
{leer}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{eintraege.map((e) => {
|
||||||
|
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||||
|
return (
|
||||||
|
<li key={e.id}>
|
||||||
|
<InnereBox className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||||
|
{e.href ? (
|
||||||
|
<Link href={e.href} className="hover:text-accent transition-colors">
|
||||||
|
{e.ziel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
e.ziel
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||||
|
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||||
|
</div>
|
||||||
|
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||||
|
</div>
|
||||||
|
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
|
||||||
|
{e.aktionen}
|
||||||
|
</InnereBox>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
|
return s.privateVerwendungen.map((v) => {
|
||||||
|
const config =
|
||||||
|
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||||
|
href: `/hosts/${v.host.id}`,
|
||||||
|
pfad: v.pfad,
|
||||||
|
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="privat"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.privat && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={rechte.testen && v.zustand === "AUSGEROLLT" && typeof config?.alias === "string"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean };
|
||||||
|
|
||||||
|
function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
|
return s.publicVerwendungen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
ziel: v.host
|
||||||
|
? `${v.zielbenutzer}@${v.host.name}`
|
||||||
|
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||||
|
...(v.host
|
||||||
|
? { href: `/hosts/${v.host.id}` }
|
||||||
|
: v.hostgruppe
|
||||||
|
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||||||
|
: {}),
|
||||||
|
pfad: "~/.ssh/authorized_keys",
|
||||||
|
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="public"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.public && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||||||
|
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||||||
|
if (s.privatGespeichert) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||||
|
<Lock className="text-ok size-3.5" aria-hidden />
|
||||||
|
verschlüsselt gespeichert
|
||||||
|
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||||||
|
{anzeigen}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.privatGeloeschtAm) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||||||
|
<LockOpen className="size-3.5" aria-hidden />
|
||||||
|
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-[14px]">
|
||||||
|
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||||
|
export default async function SchluesselSeite({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ zielkonten?: string | string[] }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||||
|
const s = await schluesselDetail(id);
|
||||||
|
if (!s) notFound();
|
||||||
|
const zielkontenParameter = (await searchParams).zielkonten;
|
||||||
|
|
||||||
|
// Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr.
|
||||||
|
const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN");
|
||||||
|
const aktiv = s.status === "AKTIV" && !imWiderruf;
|
||||||
|
const rechte: Rechte = {
|
||||||
|
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||||
|
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||||
|
};
|
||||||
|
const jetzt = new Date();
|
||||||
|
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||||
|
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const widerrufbar =
|
||||||
|
!imWiderruf &&
|
||||||
|
!(s.nachfolger?.status === "AKTIV") &&
|
||||||
|
s.kategorie !== "DEPLOYMENT" &&
|
||||||
|
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||||
|
rechte.public &&
|
||||||
|
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||||
|
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
|
||||||
|
// Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv.
|
||||||
|
const kette = await rotationsKette(s.id);
|
||||||
|
const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length;
|
||||||
|
const rotationsSperre = rotationGesperrt({
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
status: s.status,
|
||||||
|
masterKeyVersion: s.privatGespeichert ? 1 : null,
|
||||||
|
widerrufEingeleitetAm: s.widerrufEingeleitetAm,
|
||||||
|
privatZuweisungen: aktivePrivat,
|
||||||
|
});
|
||||||
|
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
|
||||||
|
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||||
|
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
|
||||||
|
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||||
|
const rotationDesVorgaengers =
|
||||||
|
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||||
|
? s.vorgaenger
|
||||||
|
: null;
|
||||||
|
const vorauswahl = (
|
||||||
|
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||||
|
)
|
||||||
|
.split(",")
|
||||||
|
.filter(Boolean);
|
||||||
|
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||||
|
const personenKey = s.kategorie === "PERSON";
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||||||
|
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||||||
|
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||||||
|
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||||||
|
titel={s.name}
|
||||||
|
untertitel={
|
||||||
|
s.person ? (
|
||||||
|
<>
|
||||||
|
Person:{" "}
|
||||||
|
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||||||
|
{s.person.name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
|
||||||
|
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||||||
|
{s.tags.map((t) => (
|
||||||
|
<Badge key={t} mono className="text-muted">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
|
||||||
|
<>
|
||||||
|
{verwalten && (
|
||||||
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{rotierbar && (
|
||||||
|
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||||
|
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{widerrufbar && widerruf && (
|
||||||
|
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{rotationDesVorgaengers && (
|
||||||
|
<Karte titel="Rotation läuft" className="border-accent">
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
||||||
|
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Rotation fortsetzen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{imWiderruf && widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Widerruf läuft"
|
||||||
|
untertitel="Der Schlüssel gilt erst als widerrufen, wenn er von allen Hosts entfernt ist."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[s.id]}
|
||||||
|
offen={widerruf.offen}
|
||||||
|
zielkonten={widerruf.zielkonten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
|
darfPrivat={rechte.privat}
|
||||||
|
ohneZielkonten={rotationLaeuft}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{abgelaufen && widerrufbar && widerruf && (
|
||||||
|
<Karte titel="Abgelaufen" className="border-danger-border">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
{s.ablaufAm ? (
|
||||||
|
<>
|
||||||
|
Das Ablaufdatum (<time dateTime={s.ablaufAm.toISOString()}>{datum(s.ablaufAm)}</time>) ist
|
||||||
|
überschritten.{" "}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene
|
||||||
|
Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run.
|
||||||
|
</p>
|
||||||
|
<WiderrufKnopf
|
||||||
|
uebersicht={widerruf}
|
||||||
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
|
beschriftung="Jetzt widerrufen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (
|
||||||
|
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||||
|
{s.nachfolger && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Ersetzt durch{" "}
|
||||||
|
<Link href={`/schluessel/${s.nachfolger.id}`} className="text-accent hover:underline">
|
||||||
|
{s.nachfolger.name}
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte as="div" innen="ohne">
|
||||||
|
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||||
|
<InfoFeld titel="Fingerprint">
|
||||||
|
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Erstellt">
|
||||||
|
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||||||
|
{datumZeit(s.angelegtAm)}
|
||||||
|
</time>
|
||||||
|
<span className="text-muted block text-[13px]">
|
||||||
|
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||||||
|
</span>
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Private Key">
|
||||||
|
<PrivatStatus
|
||||||
|
s={s}
|
||||||
|
anzeigen={
|
||||||
|
darfAnzeigen && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InfoFeld>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||||||
|
waere immer leer. */}
|
||||||
|
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||||||
|
{!personenKey && (
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Private Key ausgerollt auf"
|
||||||
|
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||||
|
eintraege={privateEintraege(s, rechte)}
|
||||||
|
leer={
|
||||||
|
s.privatGespeichert
|
||||||
|
? "Der Private Key liegt noch auf keinem Host."
|
||||||
|
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PrivateAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && s.privatGespeichert && rechte.privat}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
slug={s.slug}
|
||||||
|
passphraseGeschuetzt={s.passphraseGeschuetzt}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Public Key ausgerollt auf"
|
||||||
|
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||||
|
eintraege={publicEintraege(s, rechte)}
|
||||||
|
leer={
|
||||||
|
personenKey
|
||||||
|
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
: "Noch keinem Konto zugewiesen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PublicAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && rechte.public}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rechte.public &&
|
||||||
|
!imWiderruf &&
|
||||||
|
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run-Vorschau"
|
||||||
|
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||||
|
>
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={{ schluesselId: s.id }}
|
||||||
|
offen={
|
||||||
|
s.publicVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length +
|
||||||
|
(rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
|
||||||
|
<Karte
|
||||||
|
titel="Rotation"
|
||||||
|
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
|
||||||
|
>
|
||||||
|
{kette.length > 1 && (
|
||||||
|
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
|
||||||
|
{kette.map((glied, index) => (
|
||||||
|
<li key={glied.id} className="flex items-center gap-2">
|
||||||
|
{index > 0 && (
|
||||||
|
<span className="text-muted" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{glied.id === s.id ? (
|
||||||
|
<span className="font-medium" aria-current="true">
|
||||||
|
{glied.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
|
||||||
|
{glied.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={glied.status} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||||
|
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||||
|
{rotationsSperre.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Public Key"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
<a
|
||||||
|
href={`/api/schluessel/${s.id}/public-key`}
|
||||||
|
download={`${s.slug}.pub`}
|
||||||
|
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
{s.slug}.pub
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{darfLoeschen && (
|
||||||
|
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
|
||||||
|
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
|
||||||
|
</p>
|
||||||
|
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Rotation } from "@/components/rotation/Rotation";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
|
||||||
|
import { offeneEntfernungen } from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel rotieren" };
|
||||||
|
|
||||||
|
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
|
||||||
|
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`);
|
||||||
|
if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound();
|
||||||
|
let stand;
|
||||||
|
try {
|
||||||
|
stand = await rotationsStand(id);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RotationsFehler) notFound();
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const { alt } = stand;
|
||||||
|
|
||||||
|
const kopf = (
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Schlüssel", href: "/schluessel" },
|
||||||
|
{ titel: alt.name, href: `/schluessel/${alt.id}` },
|
||||||
|
{ titel: "Rotieren" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel={`„${alt.name}“ rotieren`}
|
||||||
|
untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation.
|
||||||
|
if (!stand.neu && stand.gesperrt) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
|
||||||
|
text={stand.gesperrt.grund}
|
||||||
|
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widerruf =
|
||||||
|
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||||
|
? await offeneEntfernungen([alt.id])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Rotation
|
||||||
|
daten={{
|
||||||
|
alt: { id: alt.id, name: alt.name },
|
||||||
|
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
|
||||||
|
schritt: stand.schritt,
|
||||||
|
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||||
|
tests: stand.neu ? stand.tests : [],
|
||||||
|
widerruf,
|
||||||
|
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
beispielSchluessel as s,
|
|
||||||
dryRunDatei,
|
|
||||||
dryRunDiff,
|
|
||||||
privatAusgerollt,
|
|
||||||
publicAusgerollt,
|
|
||||||
zusatzaktionen,
|
|
||||||
type Ausrollung,
|
|
||||||
} from "@/beispiel/schluessel";
|
|
||||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { DiffBox } from "@/components/ui/DiffBox";
|
|
||||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
|
||||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
|
||||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
|
||||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
|
||||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
|
||||||
|
|
||||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
|
||||||
export default async function SchluesselBeispiel() {
|
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
|
||||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
|
||||||
<Seitenkopf
|
|
||||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
|
||||||
titel={s.zweck}
|
|
||||||
badges={
|
|
||||||
<>
|
|
||||||
<Badge mono>{s.typ}</Badge>
|
|
||||||
<Badge>{s.kategorie}</Badge>
|
|
||||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
|
||||||
<Badge variante="messing">
|
|
||||||
<span>
|
|
||||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge variante="neutral" className="text-muted">
|
|
||||||
Beispieldaten
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
aktionen={
|
|
||||||
<>
|
|
||||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
|
||||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
|
||||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
|
||||||
Widerrufen
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Karte as="div" innen="ohne">
|
|
||||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
|
||||||
<InfoFeld titel="Fingerprint">
|
|
||||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Erstellt">
|
|
||||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
|
||||||
{datumZeit(s.erstellt)}
|
|
||||||
</time>
|
|
||||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Private Key">
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
|
||||||
<Lock className="text-ok size-3.5" aria-hidden />
|
|
||||||
verschlüsselt gespeichert ·
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
|
||||||
>
|
|
||||||
anzeigen (Re-Auth)
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</InfoFeld>
|
|
||||||
</dl>
|
|
||||||
</Karte>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Private Key ausgerollt auf"
|
|
||||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
|
||||||
eintraege={privatAusgerollt}
|
|
||||||
/>
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Public Key ausgerollt auf"
|
|
||||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
|
||||||
eintraege={publicAusgerollt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Karte
|
|
||||||
titel="Dry-Run-Vorschau"
|
|
||||||
untertitel={
|
|
||||||
<>
|
|
||||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
|
||||||
<fieldset className="mt-5">
|
|
||||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{zusatzaktionen.map((z) => (
|
|
||||||
<li key={z.id}>
|
|
||||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={z.id}
|
|
||||||
defaultChecked={z.an}
|
|
||||||
className="accent-accent size-[18px] shrink-0"
|
|
||||||
/>
|
|
||||||
<span>{z.text}</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</fieldset>
|
|
||||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
|
||||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Änderung anwenden
|
|
||||||
</Button>
|
|
||||||
<Button>Verwerfen</Button>
|
|
||||||
</div>
|
|
||||||
</Karte>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
|
||||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
|
||||||
<dd className="min-w-0">{children}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AusrollKarte({
|
|
||||||
titel,
|
|
||||||
untertitel,
|
|
||||||
eintraege,
|
|
||||||
}: {
|
|
||||||
titel: string;
|
|
||||||
untertitel: string;
|
|
||||||
eintraege: Ausrollung[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Karte
|
|
||||||
titel={titel}
|
|
||||||
untertitel={untertitel}
|
|
||||||
aktionen={
|
|
||||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Ausrollen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ul className="flex flex-col gap-3">
|
|
||||||
{eintraege.map((e) => (
|
|
||||||
<li key={`${e.ziel}${e.pfad}`}>
|
|
||||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
|
||||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
|
||||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
|
||||||
</div>
|
|
||||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
|
||||||
</InnereBox>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Karte>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel anlegen" };
|
||||||
|
|
||||||
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
|
||||||
|
const person = (await searchParams)["person"];
|
||||||
|
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
|
||||||
|
const vorgabePersonId =
|
||||||
|
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
|
||||||
|
}
|
||||||
|
titel="Schlüssel anlegen"
|
||||||
|
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
|
||||||
|
/>
|
||||||
|
<SchluesselNeu
|
||||||
|
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
|
||||||
|
vorlagen={vorlagen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
|
||||||
|
}))}
|
||||||
|
{...(vorgabePersonId ? { vorgabePersonId } : {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,274 @@
|
|||||||
|
import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
|
||||||
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Schlüssel" };
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
export default async function Schluessel() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verwendungen({ anzahl }: { anzahl: number }) {
|
||||||
|
if (anzahl === 0) return <span className="text-muted">keine</span>;
|
||||||
|
return <>{anzahl}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
|
||||||
|
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
|
||||||
|
if (!ablaufAm) return <span className="text-muted">–</span>;
|
||||||
|
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<time dateTime={ablaufAm.toISOString()} className="text-muted">
|
||||||
<Platzhalterseite
|
{datum(ablaufAm)}
|
||||||
titel="Schlüssel"
|
</time>
|
||||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
);
|
||||||
phase={2}
|
}
|
||||||
issue="#012"
|
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
if (!s.privatGespeichert) return null;
|
||||||
|
return (
|
||||||
|
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
|
||||||
|
<Lock className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Private Key gespeichert</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASSEN_FORMULAR = "massenwiderruf";
|
||||||
|
|
||||||
|
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
|
||||||
|
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
|
||||||
|
return (
|
||||||
|
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="id"
|
||||||
|
value={s.id}
|
||||||
|
form={MASSEN_FORMULAR}
|
||||||
|
disabled={gesperrt}
|
||||||
|
aria-label={`${s.name} für den Massenwiderruf auswählen`}
|
||||||
|
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
/>
|
/>
|
||||||
<p className="text-muted text-[14px]">
|
</label>
|
||||||
Prototyp ansehen:{" "}
|
);
|
||||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
}
|
||||||
Schlüsseldetail mit Beispieldaten
|
|
||||||
|
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
|
||||||
|
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||||
|
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const q = einzeln(parameter["q"]);
|
||||||
|
const kategorie = einzeln(parameter["kategorie"]);
|
||||||
|
const status = einzeln(parameter["status"]);
|
||||||
|
const liste = await schluesselListe({ q, kategorie, status });
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
|
||||||
|
const massen = darf(benutzer.rolle, "massenwiderruf");
|
||||||
|
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Schlüssel anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel={
|
||||||
|
gefiltert
|
||||||
|
? `${liste.length} Treffer`
|
||||||
|
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
|
||||||
|
}
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
role="search"
|
||||||
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Zweck, Fingerprint, Tag, Person"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/schluessel" className="flex-1 lg:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel passt zu Suche und Filter."
|
||||||
|
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{massen && (
|
||||||
|
<form
|
||||||
|
id={MASSEN_FORMULAR}
|
||||||
|
method="get"
|
||||||
|
action="/schluessel/widerruf"
|
||||||
|
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
|
||||||
|
>
|
||||||
|
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
|
||||||
|
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
|
||||||
|
Ausgewählte widerrufen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{liste.map((s) => (
|
||||||
|
<li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
|
||||||
|
{massen && <Auswahl s={s} />}
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
<WiderrufBadge s={s} />
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
|
||||||
|
{s.person && <> · {s.person.name}</>}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Schlüssel"
|
||||||
|
spalten={[
|
||||||
|
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "Typ" },
|
||||||
|
{ titel: "Kategorie" },
|
||||||
|
{ titel: "Fingerprint" },
|
||||||
|
{ titel: "Ablauf" },
|
||||||
|
{ titel: "Verwendungen" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{liste.map((s) => (
|
||||||
|
<TabellenZeile key={s.id}>
|
||||||
|
{massen && (
|
||||||
|
<TabellenZelle className="w-10">
|
||||||
|
<Auswahl s={s} />
|
||||||
|
</TabellenZelle>
|
||||||
|
)}
|
||||||
|
<TabellenZelle>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
<WiderrufBadge s={s} />
|
||||||
|
</span>
|
||||||
|
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Verwendungen anzahl={s.verwendungen} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Ban } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatusBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Massenwiderruf" };
|
||||||
|
|
||||||
|
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
||||||
|
|
||||||
|
function liste(wert: string | string[] | undefined): string[] {
|
||||||
|
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||||
|
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
|
||||||
|
0,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
||||||
|
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
||||||
|
* Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel.
|
||||||
|
*/
|
||||||
|
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const ids = liste(parameter.id);
|
||||||
|
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
||||||
|
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
||||||
|
|
||||||
|
const kopf = (
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Massenwiderruf" }]} />}
|
||||||
|
titel="Massenwiderruf"
|
||||||
|
untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Ban className="size-5" />}
|
||||||
|
titel="Keine Schlüssel gewählt"
|
||||||
|
text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen."
|
||||||
|
aktion={<Button href="/schluessel">Zur Schlüsselliste</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uebersicht = await widerrufUebersicht(ids);
|
||||||
|
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
||||||
|
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
||||||
|
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
||||||
|
const vorauswahl = liste(parameter.zielkonten);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
|
||||||
|
{nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? (
|
||||||
|
<Karte
|
||||||
|
titel={`${uebersicht.schluessel.length} Schlüssel widerrufen?`}
|
||||||
|
untertitel="Genau das passiert – erst nach Dry-Run und „Anwenden“ auf den Hosts."
|
||||||
|
>
|
||||||
|
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||||
|
<p className="text-accent mb-4 text-[13.5px]">
|
||||||
|
Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||||
|
Vormerken danach.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<WiderrufFormular uebersicht={uebersicht} ruecksprung={selbst} />
|
||||||
|
</Karte>
|
||||||
|
) : laufend.length > 0 ? (
|
||||||
|
<Karte
|
||||||
|
titel="Widerruf läuft"
|
||||||
|
untertitel="Ein gemeinsamer Dry-Run für alle Schlüssel. Widerrufen ist ein Schlüssel erst, wenn er überall entfernt ist."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={laufend.map((s) => s.id)}
|
||||||
|
offen={uebersicht.offen}
|
||||||
|
zielkonten={uebersicht.zielkonten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
ruecksprung={selbst}
|
||||||
|
darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Karte titel="Gewählte Schlüssel">
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{uebersicht.schluessel.map((s) => (
|
||||||
|
<li key={s.id} className="flex flex-wrap items-center gap-2 text-[14px]">
|
||||||
|
<Link href={`/schluessel/${s.id}`} className="text-accent hover:underline">
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
{s.person && <span className="text-muted">· {s.person}</span>}
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.eingeleitet && offeneStati(s) && (
|
||||||
|
<span className="text-danger text-[13px]">Widerruf unvollständig</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
|
||||||
|
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlageDetail } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
|
||||||
|
|
||||||
|
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
|
||||||
|
const v = await vorlageDetail(id);
|
||||||
|
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
|
||||||
|
if (!v || v.archiviertAm) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
|
||||||
|
titel={`${v.name} bearbeiten`}
|
||||||
|
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
|
||||||
|
untertitel={
|
||||||
|
v.eingebaut
|
||||||
|
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
|
||||||
|
/>
|
||||||
|
<VorlagenFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
beschreibung: v.beschreibung ?? "",
|
||||||
|
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
|
||||||
|
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
|
||||||
|
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
|
||||||
|
standardLaufzeitTage: v.standardLaufzeitTage,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoBefehle,
|
||||||
|
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
|
||||||
|
sshConfigAlias: v.sshConfigAlias ?? "",
|
||||||
|
knownHostsEintragen: v.knownHostsEintragen,
|
||||||
|
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage anlegen" };
|
||||||
|
|
||||||
|
export default async function VorlageNeu() {
|
||||||
|
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
|
||||||
|
titel="Vorlage anlegen"
|
||||||
|
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
|
||||||
|
/>
|
||||||
|
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,137 @@
|
|||||||
|
import { FileStack, Pencil, Plus } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
import { KategorieBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vorlagen" };
|
export const metadata: Metadata = { title: "Vorlagen" };
|
||||||
|
|
||||||
export default async function Vorlagen() {
|
const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0 text-[14px]">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mono({ wert }: { wert: string | null }) {
|
||||||
|
if (!wert) return <span className="text-muted">–</span>;
|
||||||
|
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
|
||||||
|
const teile = wert.split(",");
|
||||||
|
return (
|
||||||
|
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
|
||||||
|
{teile.map((teil, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{teil}
|
||||||
|
{i < teile.length - 1 && (
|
||||||
|
<>
|
||||||
|
,<wbr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
|
||||||
|
const sudo = SUDO_TEXT[v.sudoModus];
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
as="article"
|
||||||
|
titel={v.name}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/vorlagen/${v.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Pencil className="size-3.5" />}
|
||||||
|
aria-label={`${v.name} bearbeiten`}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
<KategorieBadge kategorie={v.kategorie} />
|
||||||
|
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
|
||||||
|
{v.eingebaut && <Badge>eingebaut</Badge>}
|
||||||
|
{v.standardLaufzeitTage && (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
|
||||||
|
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
|
||||||
|
<Angabe titel="Zielbenutzer">
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<Mono wert={v.zielbenutzer} />
|
||||||
|
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
|
||||||
|
{sudo && (
|
||||||
|
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
|
||||||
|
{sudo}
|
||||||
|
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="authorized_keys-Optionen">
|
||||||
|
<Mono wert={v.authorizedKeysOptionen} />
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="Alias-Muster">
|
||||||
|
<Mono wert={v.sshConfigAlias} />
|
||||||
|
</Angabe>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
|
||||||
|
export default async function Vorlagen() {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
|
||||||
|
const vorlagen = await vorlagenListe();
|
||||||
|
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Vorlage anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Vorlagen"
|
titel="Vorlagen"
|
||||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
|
||||||
phase={3}
|
aktionen={anlegen}
|
||||||
issue="#019"
|
|
||||||
/>
|
/>
|
||||||
|
{vorlagen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<FileStack className="size-5" />}
|
||||||
|
titel="Keine Vorlagen"
|
||||||
|
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,164 @@
|
|||||||
|
import { Download, Network, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
|
||||||
|
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||||
|
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Kennzahl } from "@/components/ui/Kennzahl";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { KATEGORIE_TEXT } from "@/lib/schluessel";
|
||||||
|
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||||
|
|
||||||
export default async function Zuweisungen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
|
||||||
|
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
|
||||||
|
*/
|
||||||
|
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
|
||||||
|
const filter = matrixFilterAus(await searchParams);
|
||||||
|
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
|
||||||
|
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
|
||||||
|
zuweisungsMatrix(filter),
|
||||||
|
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
const gefiltert = Boolean(
|
||||||
|
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
|
||||||
|
);
|
||||||
|
const { zaehler } = matrix;
|
||||||
|
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Zuweisungen"
|
titel="Zuweisungen"
|
||||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
|
||||||
phase={2}
|
aktionen={
|
||||||
issue="#016"
|
<a
|
||||||
|
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
|
||||||
|
download
|
||||||
|
className={buttonKlassen("sekundaer")}
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
CSV exportieren
|
||||||
|
</a>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||||
|
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
|
||||||
|
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
|
||||||
|
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
|
||||||
|
<Kennzahl
|
||||||
|
titel="Nicht synchron"
|
||||||
|
wert={zaehler.nichtSynchron}
|
||||||
|
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
|
||||||
|
hinweis="geplant, Drift, entfernen, Fehler"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
role="search"
|
||||||
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={filter.q}
|
||||||
|
placeholder="Schlüssel- oder Hostname"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
|
||||||
|
<option value="">Alle Hosts</option>
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
|
||||||
|
<option value="">Alle Personen</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="belegt"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={filter.nurBelegt}
|
||||||
|
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
|
||||||
|
className="-mx-2"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{leer ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
|
||||||
|
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Network className="size-5" />}
|
||||||
|
titel="Noch nichts zuzuordnen"
|
||||||
|
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
|
||||||
|
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
|
||||||
|
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
|
||||||
|
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
|
||||||
|
<span className="text-danger">●</span> Drift oder Fehler
|
||||||
|
</p>
|
||||||
|
<Zuweisungsmatrix
|
||||||
|
zeilen={matrix.zeilen}
|
||||||
|
spalten={matrix.spalten}
|
||||||
|
zellen={Object.fromEntries(matrix.zellen)}
|
||||||
|
darfZuweisen={darfZuweisen}
|
||||||
|
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { HostFehler } from "@/server/services/hosts";
|
||||||
|
import { privateKeyAbholen } from "@/server/services/privatekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
|
||||||
|
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
|
||||||
|
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
|
||||||
|
*
|
||||||
|
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
|
||||||
|
* nie Schluesselmaterial.
|
||||||
|
*/
|
||||||
|
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
|
||||||
|
|
||||||
|
function text(meldung: string, status: number): NextResponse {
|
||||||
|
return new NextResponse(`${meldung}\n`, {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
|
||||||
|
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
|
||||||
|
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
|
||||||
|
*/
|
||||||
|
function vonHier(anfrage: NextRequest): boolean {
|
||||||
|
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
|
||||||
|
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
|
||||||
|
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
|
||||||
|
|
||||||
|
let benutzer;
|
||||||
|
try {
|
||||||
|
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
|
||||||
|
return text(fehler.message, 403);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vorgangId } = await params;
|
||||||
|
try {
|
||||||
|
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
|
||||||
|
return new NextResponse(abholung.privateKey, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
|
||||||
|
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...KEIN_CACHE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof HostFehler) return text(fehler.message, 410);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { publicKeyExport } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
|
||||||
|
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const schluessel = await publicKeyExport((await params).id);
|
||||||
|
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
|
||||||
|
return new NextResponse(`${schluessel.publicKey}\n`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { matrixCsv, matrixFilterAus } from "@/lib/zuweisungsmatrix";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuweisungsmatrix als CSV (#025) mit denselben Filtern wie die Seite. Jede Rolle darf lesen;
|
||||||
|
* der Handler prueft das selbst, der Proxy laesst ohne Cookie ohnehin niemanden durch.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const filter = matrixFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
const { matrix } = await zuweisungsMatrix(filter);
|
||||||
|
return new NextResponse(matrixCsv(matrix), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": 'attachment; filename="sshkv-zuweisungen.csv"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel.
|
|
||||||
* Ersetzt durch das Lesemodell aus #012/#016/#017.
|
|
||||||
*/
|
|
||||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
|
||||||
import type { DiffZeile } from "@/components/ui/DiffBox";
|
|
||||||
|
|
||||||
export const beispielSchluessel = {
|
|
||||||
zweck: "Backup srv-backup01 → srv-pve01",
|
|
||||||
typ: "ed25519",
|
|
||||||
kategorie: "Dienst",
|
|
||||||
vorlage: "Backup-Zugang",
|
|
||||||
ablauf: "2026-10-30T10:00:00Z",
|
|
||||||
fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k",
|
|
||||||
erstellt: "2026-04-30T08:12:00Z",
|
|
||||||
erstelltVon: "Florian Wilde",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Ausrollung = {
|
|
||||||
ziel: string;
|
|
||||||
pfad: string;
|
|
||||||
zusatz?: string;
|
|
||||||
status: HostStatus;
|
|
||||||
statusText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const privatAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-backup01",
|
|
||||||
pfad: "~/.ssh/id_ed25519_pve01",
|
|
||||||
zusatz: "config: Host srv-pve01",
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const publicAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve01",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding',
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve02",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty',
|
|
||||||
status: "drift",
|
|
||||||
statusText: "Optionen geändert",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys";
|
|
||||||
|
|
||||||
export const dryRunDiff: DiffZeile[] = [
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" },
|
|
||||||
{
|
|
||||||
art: "kontext",
|
|
||||||
text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "entfernt",
|
|
||||||
text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "hinzu",
|
|
||||||
text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Ende ---" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const zusatzaktionen = [
|
|
||||||
{ id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true },
|
|
||||||
{ id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true },
|
|
||||||
{ id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false },
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
import { abgleichAktion } from "@/server/actions/abgleich";
|
||||||
|
import type { IstZustand } from "@/server/queries/abgleich";
|
||||||
|
import { UebernehmenDialog } from "./UebernehmenDialog";
|
||||||
|
|
||||||
|
/** Abweichungen und Ist-Zustand eines Hosts (#021, #022). */
|
||||||
|
const ART_TEXT: Record<string, string> = {
|
||||||
|
UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel",
|
||||||
|
FEHLENDER_SCHLUESSEL: "Schlüssel fehlt",
|
||||||
|
ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert",
|
||||||
|
DATEI_GEAENDERT: "Block beschädigt",
|
||||||
|
RECHTE: "Rechte",
|
||||||
|
HOSTKEY_GEAENDERT: "Host-Key geändert",
|
||||||
|
PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HERKUNFT: Record<string, { text: string; variante: "ok" | "neutral" | "gefahr" }> = {
|
||||||
|
verwaltet: { text: "verwaltet", variante: "ok" },
|
||||||
|
bekannt: { text: "bekannt", variante: "neutral" },
|
||||||
|
fremd: { text: "unbekannt", variante: "gefahr" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AbgleichKarten({
|
||||||
|
hostId,
|
||||||
|
zustand,
|
||||||
|
darfAbgleichen,
|
||||||
|
darfUebernehmen,
|
||||||
|
darfVerwalten,
|
||||||
|
}: {
|
||||||
|
hostId: string;
|
||||||
|
zustand: IstZustand;
|
||||||
|
darfAbgleichen: boolean;
|
||||||
|
darfUebernehmen: boolean;
|
||||||
|
darfVerwalten: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Karte
|
||||||
|
titel="Abweichungen"
|
||||||
|
untertitel="Unterschiede zwischen Soll und Host beim letzten Abgleich"
|
||||||
|
aktionen={
|
||||||
|
darfAbgleichen ? (
|
||||||
|
<VorgangsKnopf aktion={abgleichAktion.bind(null, hostId)} beschriftung="Jetzt abgleichen" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{zustand.abweichungen.length === 0 ? (
|
||||||
|
<p className="text-ok text-[14px]">● Keine offenen Abweichungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{zustand.abweichungen.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-danger text-[14px] font-medium">
|
||||||
|
● {ART_TEXT[a.art] ?? a.art}
|
||||||
|
{a.zielbenutzer && (
|
||||||
|
<span className="text-muted font-mono text-[13px]"> · {a.zielbenutzer}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-text text-[14px]">{a.text}</span>
|
||||||
|
<span className="text-muted font-mono text-[12px] break-all">
|
||||||
|
{a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Ist-Zustand je Benutzer"
|
||||||
|
untertitel="Alle Schlüssel in authorized_keys – auch die, die sshkv nicht verwaltet"
|
||||||
|
>
|
||||||
|
{zustand.beobachtungen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
titel="Noch nicht abgeglichen"
|
||||||
|
text="Nach dem ersten Abgleich steht hier, was wirklich auf dem Host liegt."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{zustand.beobachtungen.map((b) => (
|
||||||
|
<section key={b.zielbenutzer} className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-text font-mono text-[14px] font-medium">
|
||||||
|
{b.zielbenutzer}{" "}
|
||||||
|
<span className="text-muted text-[12px]">· {datumZeit(b.beobachtetAm)}</span>
|
||||||
|
</h3>
|
||||||
|
{b.eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted text-[13.5px]">Datei leer.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{b.eintraege.map((e) => {
|
||||||
|
const herkunft = HERKUNFT[e.herkunft]!;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${e.fingerprint}-${e.imBlock}`}
|
||||||
|
className="border-border rounded-box flex flex-col gap-2 border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-mono text-[12.5px] break-all">
|
||||||
|
<span className="text-muted">{e.typ}</span> {e.fingerprint}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted font-mono text-[12px] break-all">
|
||||||
|
{e.kommentar || "(ohne Kommentar)"}
|
||||||
|
{e.optionen ? ` · ${e.optionen}` : ""}
|
||||||
|
{e.imBlock ? " · im verwalteten Block" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variante={herkunft.variante}>{herkunft.text}</Badge>
|
||||||
|
{e.herkunft === "fremd" && darfUebernehmen && (
|
||||||
|
<UebernehmenDialog
|
||||||
|
hostId={hostId}
|
||||||
|
zielbenutzer={b.zielbenutzer}
|
||||||
|
fingerprint={e.fingerprint}
|
||||||
|
kommentar={e.kommentar}
|
||||||
|
personen={zustand.personen}
|
||||||
|
darfVerwalten={darfVerwalten}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { alleAbgleichenAktion } from "@/server/actions/abgleich";
|
||||||
|
|
||||||
|
/** „Jetzt abgleichen“ auf der Uebersicht (#021): reiht fuer jeden Host einen Abgleich ein. */
|
||||||
|
export function AlleAbgleichenKnopf() {
|
||||||
|
const [meldung, setMeldung] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col items-start gap-1">
|
||||||
|
<Button
|
||||||
|
icon={<RefreshCw className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await alleAbgleichenAktion();
|
||||||
|
setMeldung(antwort.fehler ?? `${antwort.anzahl ?? 0} Abgleich(e) eingereiht.`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Jetzt abgleichen
|
||||||
|
</Button>
|
||||||
|
{meldung && (
|
||||||
|
<span role="status" className="text-muted text-[12.5px]">
|
||||||
|
{meldung}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { uebernehmenAktion } from "@/server/actions/abgleich";
|
||||||
|
|
||||||
|
/** Fremden Key aus dem Ist-Zustand uebernehmen (#022). */
|
||||||
|
export function UebernehmenDialog({
|
||||||
|
hostId,
|
||||||
|
zielbenutzer,
|
||||||
|
fingerprint,
|
||||||
|
kommentar,
|
||||||
|
personen,
|
||||||
|
darfVerwalten,
|
||||||
|
}: {
|
||||||
|
hostId: string;
|
||||||
|
zielbenutzer: string;
|
||||||
|
fingerprint: string;
|
||||||
|
kommentar: string;
|
||||||
|
personen: { id: string; name: string }[];
|
||||||
|
darfVerwalten: boolean;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [verwalten, setVerwalten] = useState(darfVerwalten);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await uebernehmenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) setOffen(false);
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (offen && !element.open) element.showModal();
|
||||||
|
if (!offen && element.open) element.close();
|
||||||
|
}, [offen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button klein onClick={() => setOffen(true)}>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label="Schlüssel übernehmen"
|
||||||
|
onClose={() => setOffen(false)}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<h2 className="font-display mb-1 text-[18px] font-semibold">Schlüssel übernehmen</h2>
|
||||||
|
<p className="text-muted mb-4 font-mono text-[12.5px] break-all">
|
||||||
|
{zielbenutzer} · {fingerprint}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="hostId" value={hostId} />
|
||||||
|
<input type="hidden" name="zielbenutzer" value={zielbenutzer} />
|
||||||
|
<input type="hidden" name="fingerprint" value={fingerprint} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Feld beschriftung="Zweck / Name" name="name" defaultValue={kommentar} required />
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue="PERSON">
|
||||||
|
<option value="PERSON">Person</option>
|
||||||
|
<option value="DIENST">Dienst</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Person (optional)" name="personId" defaultValue="">
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{darfVerwalten && (
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="verwalten"
|
||||||
|
value="ja"
|
||||||
|
checked={verwalten}
|
||||||
|
onChange={(e) => setVerwalten(e.target.checked)}
|
||||||
|
beschriftung="In den verwalteten Block holen"
|
||||||
|
erklaerung="Beim nächsten Ausrollen wandert die Zeile in den Block, die alte Zeile außerhalb wird entfernt – mit Dry-Run."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Übernehme …" : "Übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import { warten } from "@/components/vorgang/warten";
|
||||||
|
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
|
||||||
|
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||||
|
import {
|
||||||
|
assistentErzeugenAktion,
|
||||||
|
assistentZielkontoAktion,
|
||||||
|
assistentZuweisenAktion,
|
||||||
|
type SchrittAntwort,
|
||||||
|
} from "@/server/actions/assistent";
|
||||||
|
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { AssistentDaten } from "@/server/queries/assistent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
|
||||||
|
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
|
||||||
|
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
|
||||||
|
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
|
||||||
|
*/
|
||||||
|
type Werte = {
|
||||||
|
vorlageId: string;
|
||||||
|
zweck: string;
|
||||||
|
ablaufAm: string;
|
||||||
|
quelleHostId: string;
|
||||||
|
quelleBenutzer: string;
|
||||||
|
pfad: string;
|
||||||
|
zielHostId: string;
|
||||||
|
zielBenutzer: string;
|
||||||
|
anlegen: boolean;
|
||||||
|
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
sudoBefehle: string;
|
||||||
|
optionen: string;
|
||||||
|
quellIp: string;
|
||||||
|
alias: string;
|
||||||
|
hostName: string;
|
||||||
|
port: string;
|
||||||
|
knownHosts: boolean;
|
||||||
|
fingerprintGeprueft: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEER: Werte = {
|
||||||
|
vorlageId: "",
|
||||||
|
zweck: "",
|
||||||
|
ablaufAm: "",
|
||||||
|
quelleHostId: "",
|
||||||
|
quelleBenutzer: "root",
|
||||||
|
pfad: "",
|
||||||
|
zielHostId: "",
|
||||||
|
zielBenutzer: "root",
|
||||||
|
anlegen: false,
|
||||||
|
sudoModus: "KEINER",
|
||||||
|
sudoBefehle: "",
|
||||||
|
optionen: "",
|
||||||
|
quellIp: "",
|
||||||
|
alias: "",
|
||||||
|
hostName: "",
|
||||||
|
port: "22",
|
||||||
|
knownHosts: true,
|
||||||
|
fingerprintGeprueft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
|
||||||
|
|
||||||
|
type Ablauf = {
|
||||||
|
schluesselId?: string;
|
||||||
|
zielkontoFertig?: boolean;
|
||||||
|
privatVerwendungId?: string;
|
||||||
|
laeuft?: string;
|
||||||
|
fehler?: string;
|
||||||
|
bestaetigen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function datumIn(tage: number): string {
|
||||||
|
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kurz(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 40) || "zugang"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||||
|
const [schritt, setSchritt] = useState(0);
|
||||||
|
const [w, setW] = useState<Werte>(LEER);
|
||||||
|
const [ablauf, setAblauf] = useState<Ablauf>({});
|
||||||
|
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
|
||||||
|
|
||||||
|
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
|
||||||
|
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
|
||||||
|
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
|
||||||
|
|
||||||
|
function vorlageWaehlen(id: string) {
|
||||||
|
const v = daten.vorlagen.find((x) => x.id === id);
|
||||||
|
setW((alt) => ({
|
||||||
|
...alt,
|
||||||
|
vorlageId: id,
|
||||||
|
...(v
|
||||||
|
? {
|
||||||
|
zielBenutzer: v.zielbenutzer,
|
||||||
|
anlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoBefehle.join("\n"),
|
||||||
|
knownHosts: v.knownHostsEintragen,
|
||||||
|
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
|
||||||
|
function zielVorbelegen(zielId: string) {
|
||||||
|
const b = daten.hosts.find((h) => h.id === zielId);
|
||||||
|
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
|
||||||
|
const werte = {
|
||||||
|
quellHost: quelle?.name,
|
||||||
|
quellHostIp: quellIp,
|
||||||
|
zielHost: b?.name,
|
||||||
|
zielHostIp: b?.adresse,
|
||||||
|
};
|
||||||
|
const aufloesen = (muster: string | null | undefined) => {
|
||||||
|
if (!muster) return "";
|
||||||
|
try {
|
||||||
|
return platzhalterErsetzen(muster, werte);
|
||||||
|
} catch {
|
||||||
|
return muster;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setW((alt) => ({
|
||||||
|
...alt,
|
||||||
|
zielHostId: zielId,
|
||||||
|
quellIp,
|
||||||
|
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
|
||||||
|
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
|
||||||
|
hostName: b ? b.adresse : "",
|
||||||
|
port: b ? String(b.port) : "22",
|
||||||
|
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
|
||||||
|
fingerprintGeprueft: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
|
||||||
|
let sudoVorschau: string | null = null;
|
||||||
|
let sudoFehler: string | undefined;
|
||||||
|
if (w.anlegen) {
|
||||||
|
try {
|
||||||
|
sudoVorschau = sudoersInhalt(
|
||||||
|
w.zielBenutzer,
|
||||||
|
w.sudoModus,
|
||||||
|
w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
sudoFehler = (fehler as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schrittOk = [
|
||||||
|
w.zweck.trim().length > 0,
|
||||||
|
Boolean(w.quelleHostId && w.quelleBenutzer),
|
||||||
|
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
|
||||||
|
!offenePlatzhalter &&
|
||||||
|
!sudoFehler &&
|
||||||
|
(!w.knownHosts || w.fingerprintGeprueft),
|
||||||
|
true,
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ausfuehren() {
|
||||||
|
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
|
||||||
|
const melden = (neu: Partial<Ablauf>) => {
|
||||||
|
stand = { ...stand, ...neu };
|
||||||
|
setAblauf(stand);
|
||||||
|
};
|
||||||
|
const pruefen = (antwort: SchrittAntwort) => {
|
||||||
|
if (antwort.fehler || antwort.bestaetigen) {
|
||||||
|
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!stand.schluesselId) {
|
||||||
|
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
|
||||||
|
const antwort = await assistentErzeugenAktion({
|
||||||
|
name: w.zweck.trim(),
|
||||||
|
vorlageId: w.vorlageId || null,
|
||||||
|
ablaufAm: w.ablaufAm || null,
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
const ergebnis = await warten(antwort.vorgangId!);
|
||||||
|
if (ergebnis.status !== "ERLEDIGT")
|
||||||
|
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
|
||||||
|
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
|
||||||
|
}
|
||||||
|
if (w.anlegen && !stand.zielkontoFertig) {
|
||||||
|
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
|
||||||
|
const antwort = await assistentZielkontoAktion({
|
||||||
|
hostId: w.zielHostId,
|
||||||
|
benutzer: w.zielBenutzer,
|
||||||
|
modus: w.sudoModus,
|
||||||
|
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
const ergebnis = await warten(antwort.vorgangId!);
|
||||||
|
if (ergebnis.status !== "ERLEDIGT")
|
||||||
|
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
|
||||||
|
melden({ zielkontoFertig: true });
|
||||||
|
}
|
||||||
|
if (!stand.privatVerwendungId) {
|
||||||
|
melden({ laeuft: "Zuweisungen werden angelegt …" });
|
||||||
|
const antwort = await assistentZuweisenAktion({
|
||||||
|
schluesselId: stand.schluesselId!,
|
||||||
|
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
|
||||||
|
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
|
||||||
|
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
melden({ privatVerwendungId: antwort.privatVerwendungId });
|
||||||
|
}
|
||||||
|
melden({ laeuft: undefined });
|
||||||
|
setSchritt(4);
|
||||||
|
} catch (fehler) {
|
||||||
|
melden({ laeuft: undefined, fehler: (fehler as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
|
||||||
|
|
||||||
|
{schritt === 0 && (
|
||||||
|
<Karte
|
||||||
|
titel="Vorlage & Zweck"
|
||||||
|
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Vorlage"
|
||||||
|
name="vorlageId"
|
||||||
|
value={w.vorlageId}
|
||||||
|
onChange={(e) => vorlageWaehlen(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– ohne Vorlage –</option>
|
||||||
|
{daten.vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="zweck"
|
||||||
|
value={w.zweck}
|
||||||
|
onChange={(e) => setze("zweck", e.target.value)}
|
||||||
|
placeholder="Backup srv-backup01 → srv-pve01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum (optional)"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
value={w.ablaufAm}
|
||||||
|
onChange={(e) => setze("ablaufAm", e.target.value)}
|
||||||
|
hinweis="Projektbezogene Zugänge sollten ablaufen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 1 && (
|
||||||
|
<Karte
|
||||||
|
titel="Quelle – Host A"
|
||||||
|
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Host A"
|
||||||
|
name="quelleHostId"
|
||||||
|
value={w.quelleHostId}
|
||||||
|
onChange={(e) => setze("quelleHostId", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Host wählen …</option>
|
||||||
|
{daten.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer auf A"
|
||||||
|
name="quelleBenutzer"
|
||||||
|
value={w.quelleBenutzer}
|
||||||
|
onChange={(e) => setze("quelleBenutzer", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Pfad des Private Keys"
|
||||||
|
name="pfad"
|
||||||
|
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
|
||||||
|
onChange={(e) => setze("pfad", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 2 && (
|
||||||
|
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Host B"
|
||||||
|
name="zielHostId"
|
||||||
|
value={w.zielHostId}
|
||||||
|
onChange={(e) => zielVorbelegen(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Host wählen …</option>
|
||||||
|
{daten.hosts
|
||||||
|
.filter((h) => h.id !== w.quelleHostId)
|
||||||
|
.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer auf B"
|
||||||
|
name="zielBenutzer"
|
||||||
|
value={w.zielBenutzer}
|
||||||
|
onChange={(e) => setze("zielBenutzer", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
{w.zielBenutzer === "root" && (
|
||||||
|
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||||
|
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
|
||||||
|
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
|
||||||
|
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
|
||||||
|
checked={w.anlegen}
|
||||||
|
onChange={(e) => setze("anlegen", e.target.checked)}
|
||||||
|
/>
|
||||||
|
{w.anlegen && (
|
||||||
|
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="sudo"
|
||||||
|
name="sudoModus"
|
||||||
|
value={w.sudoModus}
|
||||||
|
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
|
||||||
|
>
|
||||||
|
<option value="KEINER">keine sudo-Rechte</option>
|
||||||
|
<option value="BEFEHLE">bestimmte Befehle</option>
|
||||||
|
<option value="VOLL">voller sudo-Zugriff</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
{w.sudoModus === "BEFEHLE" && (
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
|
||||||
|
name="sudoBefehle"
|
||||||
|
value={w.sudoBefehle}
|
||||||
|
onChange={(e) => setze("sudoBefehle", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
|
||||||
|
{sudoVorschau && (
|
||||||
|
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
|
||||||
|
{sudoVorschau}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quelle && !istIpAdresse(quelle.adresse) && (
|
||||||
|
<Feld
|
||||||
|
beschriftung={`IP von ${quelle.name} (für from="…")`}
|
||||||
|
name="quellIp"
|
||||||
|
value={w.quellIp}
|
||||||
|
onChange={(e) => setze("quellIp", e.target.value)}
|
||||||
|
mono
|
||||||
|
hinweis="Die Adresse, unter der B den Host A sieht."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Einschränkungen in authorized_keys"
|
||||||
|
name="optionen"
|
||||||
|
value={w.optionen}
|
||||||
|
onChange={(e) => setze("optionen", e.target.value)}
|
||||||
|
mono
|
||||||
|
placeholder={
|
||||||
|
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
|
||||||
|
}
|
||||||
|
hinweis={
|
||||||
|
offenePlatzhalter
|
||||||
|
? "Es sind noch Platzhalter offen."
|
||||||
|
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Alias auf A"
|
||||||
|
name="alias"
|
||||||
|
value={w.alias}
|
||||||
|
onChange={(e) => setze("alias", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="HostName (aus Sicht von A)"
|
||||||
|
name="hostName"
|
||||||
|
value={w.hostName}
|
||||||
|
onChange={(e) => setze("hostName", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
value={w.port}
|
||||||
|
onChange={(e) => setze("port", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Host-Key von B in known_hosts von A eintragen"
|
||||||
|
checked={w.knownHosts}
|
||||||
|
onChange={(e) => setze("knownHosts", e.target.checked)}
|
||||||
|
/>
|
||||||
|
{w.knownHosts && ziel && (
|
||||||
|
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
|
||||||
|
{ziel.hostKeys.map((k) => (
|
||||||
|
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
|
||||||
|
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Fingerprint von B ist bestätigt"
|
||||||
|
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
|
||||||
|
checked={w.fingerprintGeprueft}
|
||||||
|
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 3 && (
|
||||||
|
<Karte
|
||||||
|
titel="Zusammenfassung"
|
||||||
|
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
|
||||||
|
>
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<dt className="text-muted">Schlüssel</dt>
|
||||||
|
<dd>
|
||||||
|
ed25519 · {w.zweck}
|
||||||
|
{w.ablaufAm && (
|
||||||
|
<span className="text-accent">
|
||||||
|
{" "}
|
||||||
|
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Private Key</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">~/.ssh/config auf A</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Public Key</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
{w.optionen && `${w.optionen} · `}
|
||||||
|
{w.zielBenutzer}@{ziel?.name}
|
||||||
|
</dd>
|
||||||
|
{w.anlegen && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted">Zielbenutzer</dt>
|
||||||
|
<dd>
|
||||||
|
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
|
||||||
|
{w.sudoModus === "KEINER" ? (
|
||||||
|
"ohne sudo"
|
||||||
|
) : w.sudoModus === "VOLL" ? (
|
||||||
|
<span className="text-danger">voller sudo-Zugriff</span>
|
||||||
|
) : (
|
||||||
|
"sudo für bestimmte Befehle"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
|
||||||
|
<p className="text-accent mt-4 text-[13.5px]">
|
||||||
|
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||||
|
Ausführen danach.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
|
||||||
|
<Formularfehler text={ablauf.fehler} />
|
||||||
|
{ablauf.bestaetigen && (
|
||||||
|
<a
|
||||||
|
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
|
||||||
|
erneut ausführen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{ablauf.laeuft && (
|
||||||
|
<p role="status" className="text-muted text-[14px]">
|
||||||
|
{ablauf.laeuft}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Play className="size-4" />}
|
||||||
|
disabled={Boolean(ablauf.laeuft)}
|
||||||
|
onClick={() => void ausfuehren()}
|
||||||
|
>
|
||||||
|
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && ablauf.schluesselId && (
|
||||||
|
<>
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run und Ausrollen"
|
||||||
|
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
|
||||||
|
>
|
||||||
|
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
|
||||||
|
</Karte>
|
||||||
|
<Karte
|
||||||
|
titel="Verbindung testen"
|
||||||
|
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
|
||||||
|
>
|
||||||
|
{ablauf.privatVerwendungId && (
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
|
||||||
|
beschriftung="Verbindung von A nach B testen"
|
||||||
|
variante="primaer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-muted mt-3 text-[13.5px]">
|
||||||
|
Zum Schlüssel:{" "}
|
||||||
|
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
|
||||||
|
{w.zweck}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt < 3 && (
|
||||||
|
<div className="flex flex-wrap justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeft className="size-4" />}
|
||||||
|
disabled={schritt === 0}
|
||||||
|
onClick={() => setSchritt(schritt - 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<ArrowRight className="size-4" />}
|
||||||
|
disabled={!schrittOk[schritt]}
|
||||||
|
onClick={() => {
|
||||||
|
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
|
||||||
|
setSchritt(schritt + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schritt === 3 && !ablauf.schluesselId && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeft className="size-4" />}
|
||||||
|
disabled={Boolean(ablauf.laeuft)}
|
||||||
|
onClick={() => setSchritt(2)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „+ Ausrollen“: Knopf mit nativem Dialog (#016, #017). Offen/zu als Zustand, der Effekt
|
||||||
|
* gleicht den Dialog an – so liest kein Render-Pfad die Ref.
|
||||||
|
*/
|
||||||
|
export function AusrollDialog({
|
||||||
|
titel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
children: (schliessen: () => void) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (offen && !element.open) element.showModal();
|
||||||
|
if (!offen && element.open) element.close();
|
||||||
|
}, [offen]);
|
||||||
|
|
||||||
|
const schliessen = () => setOffen(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button klein icon={<Plus className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
Ausrollen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label={titel}
|
||||||
|
onClose={schliessen}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<h2 className="font-display text-[18px] font-semibold">{titel}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={schliessen}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{offen && children(schliessen)}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play, ScanSearch } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DiffBox } from "@/components/ui/DiffBox";
|
||||||
|
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen";
|
||||||
|
import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker
|
||||||
|
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||||||
|
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||||||
|
*/
|
||||||
|
type DateiPlan = {
|
||||||
|
art: "privateKey" | "publicKey" | "config" | "knownHosts";
|
||||||
|
pfad: string;
|
||||||
|
aktion: "anlegen" | "aendern" | "keine" | "entfernen";
|
||||||
|
diff?: DiffZeile[];
|
||||||
|
hinweis?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanZiel = {
|
||||||
|
art?: "public" | "privat";
|
||||||
|
dateien?: DateiPlan[];
|
||||||
|
hostName: string;
|
||||||
|
zielbenutzer: string;
|
||||||
|
pfad?: string;
|
||||||
|
geaendert: boolean;
|
||||||
|
diff: DiffZeile[];
|
||||||
|
hinweise: string[];
|
||||||
|
fehler?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AusrollenPanel({
|
||||||
|
auswahl,
|
||||||
|
offen,
|
||||||
|
nachAusrollen,
|
||||||
|
}: {
|
||||||
|
auswahl: AusrollAuswahl;
|
||||||
|
offen: number;
|
||||||
|
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
|
||||||
|
nachAusrollen?: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||||
|
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const trockenlauf = useVorgang(trockenlaufId);
|
||||||
|
const ausrollen = useVorgang(ausrollenId);
|
||||||
|
|
||||||
|
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
|
||||||
|
const rueckruf = useRef(nachAusrollen);
|
||||||
|
useEffect(() => {
|
||||||
|
rueckruf.current = nachAusrollen;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
|
||||||
|
router.refresh();
|
||||||
|
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
|
||||||
|
}, [ausrollen.stand, router]);
|
||||||
|
|
||||||
|
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||||
|
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||||
|
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
|
||||||
|
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
|
||||||
|
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
|
||||||
|
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||||
|
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
icon={<ScanSearch className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
setAusrollenId(undefined);
|
||||||
|
const antwort = await trockenlaufAktion(auswahl);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setTrockenlaufId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuftNoch(trockenlauf.stand) ? "Trockenlauf läuft …" : "Dry-Run starten"}
|
||||||
|
</Button>
|
||||||
|
{offen > 0 && !plan && (
|
||||||
|
<span className="text-accent text-[13.5px]">{offen} Zuweisung(en) noch nicht ausgerollt.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && (
|
||||||
|
<p role="status" className="text-danger text-[14px]">
|
||||||
|
{fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan?.map((ziel) => (
|
||||||
|
<div key={`${ziel.hostName}-${ziel.zielbenutzer}`} className="flex flex-col gap-2">
|
||||||
|
{ziel.fehler ? (
|
||||||
|
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||||||
|
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||||||
|
</p>
|
||||||
|
) : ziel.art === "privat" ? (
|
||||||
|
<PrivatPlanAnzeige ziel={ziel} />
|
||||||
|
) : (
|
||||||
|
<DiffBox
|
||||||
|
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||||
|
zeilen={ziel.geaendert ? ziel.diff : [{ art: "kontext", text: "Keine Änderung nötig." }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ziel.hinweise.map((h) => (
|
||||||
|
<p key={h} className="text-accent text-[13px]">
|
||||||
|
▲ {h}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
|
||||||
|
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||||
|
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||||
|
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||||
|
Backup jeder geänderten Datei anlegen (
|
||||||
|
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
|
||||||
|
denen keine zweite Kopie liegen bleiben soll
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Play className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await ausrollenAktion(trockenlaufId!);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setAusrollenId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuftNoch(ausrollen.stand)
|
||||||
|
? "Wird ausgerollt …"
|
||||||
|
: nurZustand
|
||||||
|
? "Zustand übernehmen"
|
||||||
|
: `Änderung anwenden (${aenderungen})`}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plan && aenderungen === 0 && !fertig && (
|
||||||
|
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||||
|
)}
|
||||||
|
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||||
|
<p role="status" className="text-danger text-[14px]">
|
||||||
|
{ausrollen.stand.fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fertig && <p className="text-ok text-[14px]">Ausgerollt.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AKTION_TEXT: Record<DateiPlan["aktion"], string> = {
|
||||||
|
anlegen: "wird angelegt",
|
||||||
|
aendern: "wird geändert",
|
||||||
|
entfernen: "wird entfernt",
|
||||||
|
keine: "unverändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private-Key-Ziel (#017): die Dateien unter ~/.ssh einzeln. Vom Private Key steht nur die
|
||||||
|
* Aktion da – sein Inhalt erreicht den Browser nie.
|
||||||
|
*/
|
||||||
|
function PrivatPlanAnzeige({ ziel }: { ziel: PlanZiel }) {
|
||||||
|
const dateien = ziel.dateien ?? [];
|
||||||
|
return (
|
||||||
|
<div className="border-border bg-bg rounded-box flex flex-col gap-3 border p-4">
|
||||||
|
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||||
|
{ziel.zielbenutzer}@{ziel.hostName} · Private Key, ~/.ssh/config, known_hosts
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-[12.5px]">
|
||||||
|
{dateien
|
||||||
|
.filter((d) => d.art === "privateKey" || d.art === "publicKey")
|
||||||
|
.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.pfad}
|
||||||
|
className={
|
||||||
|
d.aktion === "keine" ? "text-muted" : d.aktion === "entfernen" ? "text-danger" : "text-ok"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.aktion === "keine" ? " " : d.aktion === "entfernen" ? "−" : "+"} {d.pfad} –{" "}
|
||||||
|
{AKTION_TEXT[d.aktion]}
|
||||||
|
{d.art === "privateKey" && d.aktion !== "keine" && d.aktion !== "entfernen"
|
||||||
|
? " (Inhalt wird nicht angezeigt)"
|
||||||
|
: ""}
|
||||||
|
{d.hinweis ? ` · ${d.hinweis}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dateien
|
||||||
|
.filter((d) => (d.art === "config" || d.art === "knownHosts") && d.aktion !== "keine" && d.diff)
|
||||||
|
.map((d) => (
|
||||||
|
<DiffBox key={d.pfad} datei={`${ziel.zielbenutzer}@${ziel.hostName}:${d.pfad}`} zeilen={d.diff!} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import {
|
||||||
|
privateEntfernenAktion,
|
||||||
|
publicEntfernenAktion,
|
||||||
|
verbindungTestenAktion,
|
||||||
|
} from "@/server/actions/verwendungen";
|
||||||
|
|
||||||
|
/** Je Eintrag: Entfernen vormerken, bei Private Keys mit Alias den Verbindungstest (#016/#017/#020). */
|
||||||
|
export function EintragAktionen({
|
||||||
|
art,
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
darfEntfernen,
|
||||||
|
testbar,
|
||||||
|
}: {
|
||||||
|
art: "public" | "privat";
|
||||||
|
verwendungId: string;
|
||||||
|
schluesselId: string;
|
||||||
|
darfEntfernen: boolean;
|
||||||
|
testbar: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
{testbar && (
|
||||||
|
<VorgangsKnopf
|
||||||
|
klein
|
||||||
|
aktion={verbindungTestenAktion.bind(null, verwendungId)}
|
||||||
|
beschriftung="Verbindung testen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{darfEntfernen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft}
|
||||||
|
title="Zuweisung entfernen"
|
||||||
|
aria-label="Zuweisung entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await (art === "public" ? publicEntfernenAktion : privateEntfernenAktion)(
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-muted hover:text-danger hover:bg-danger-soft rounded-feld flex size-11 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { privateZuweisenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key auf einen Quell-Host legen (#017), optional mit ~/.ssh/config-Eintrag und dem
|
||||||
|
* Host-Key des Ziels in known_hosts – das, was ein Cronjob auf dem Quell-Host braucht.
|
||||||
|
*/
|
||||||
|
export function PrivateZuweisenFormular({
|
||||||
|
schluesselId,
|
||||||
|
slug,
|
||||||
|
auswahl,
|
||||||
|
passphraseGeschuetzt,
|
||||||
|
beiErfolg,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
slug: string;
|
||||||
|
auswahl: ZielAuswahl & { hostsDetails: { id: string; name: string; adresse: string; port: number }[] };
|
||||||
|
passphraseGeschuetzt: boolean;
|
||||||
|
beiErfolg?: () => void;
|
||||||
|
}) {
|
||||||
|
const [mitConfig, setMitConfig] = useState(true);
|
||||||
|
const [zielHostId, setZielHostId] = useState("");
|
||||||
|
const ziel = auswahl.hostsDetails.find((h) => h.id === zielHostId);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await privateZuweisenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{passphraseGeschuetzt && (
|
||||||
|
<p className="border-border-strong text-accent rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||||
|
▲ Dieser Private Key ist passphrasengeschützt – für automatische Anmeldungen (Host-zu-Host)
|
||||||
|
ungeeignet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Quell-Host (bekommt den Private Key)"
|
||||||
|
name="hostId"
|
||||||
|
defaultValue=""
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Host wählen …
|
||||||
|
</option>
|
||||||
|
{auswahl.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Pfad"
|
||||||
|
name="pfad"
|
||||||
|
defaultValue={`~/.ssh/id_ed25519_${slug}`}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Eintrag in ~/.ssh/config anlegen"
|
||||||
|
erklaerung="Dann genügt auf dem Quell-Host ein einfaches „ssh <Alias>“."
|
||||||
|
checked={mitConfig}
|
||||||
|
onChange={(e) => setMitConfig(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{mitConfig && (
|
||||||
|
<div className="border-border flex flex-col gap-4 border-l-2 pl-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Ziel-Host (für known_hosts)"
|
||||||
|
name="zielHostId"
|
||||||
|
value={zielHostId}
|
||||||
|
onChange={(e) => setZielHostId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– kein verwalteter Host –</option>
|
||||||
|
{auswahl.hostsDetails.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
key={`alias-${zielHostId}`}
|
||||||
|
beschriftung="Alias"
|
||||||
|
name="alias"
|
||||||
|
defaultValue={ziel ? ziel.name : ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`hn-${zielHostId}`}
|
||||||
|
beschriftung="HostName"
|
||||||
|
name="hostName"
|
||||||
|
defaultValue={ziel?.adresse ?? ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
hinweis="So, wie der Quell-Host das Ziel erreicht."
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer am Ziel"
|
||||||
|
name="sshBenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`port-${zielHostId}`}
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
defaultValue={ziel?.port ?? 22}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Auswahlliste beschriftung="Beim Widerruf" name="beimWiderruf" defaultValue="LOESCHEN">
|
||||||
|
<option value="LOESCHEN">Datei löschen</option>
|
||||||
|
<option value="UMBENENNEN">Datei umbenennen (…sshkv-widerrufen…)</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { publicZuweisenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key einem Host oder einer Hostgruppe zuweisen (#016). Die Zuweisung allein aendert
|
||||||
|
* noch nichts auf dem Host – erst Trockenlauf und Ausrollen.
|
||||||
|
*/
|
||||||
|
export function PublicZuweisenFormular({
|
||||||
|
schluesselId,
|
||||||
|
auswahl,
|
||||||
|
beiErfolg,
|
||||||
|
vorauswahl,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
auswahl: ZielAuswahl;
|
||||||
|
beiErfolg?: () => void;
|
||||||
|
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
|
||||||
|
vorauswahl?: string;
|
||||||
|
}) {
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
|
||||||
|
<option value="" disabled>
|
||||||
|
Host oder Hostgruppe wählen …
|
||||||
|
</option>
|
||||||
|
<optgroup label="Hosts">
|
||||||
|
{auswahl.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={`host:${h.id}`}>
|
||||||
|
{h.name}
|
||||||
|
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{auswahl.gruppen.length > 0 && (
|
||||||
|
<optgroup label="Hostgruppen">
|
||||||
|
{auswahl.gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={`gruppe:${g.id}`}>
|
||||||
|
{g.name} ({g.mitglieder} Hosts)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</Auswahlliste>
|
||||||
|
{zustand.feldfehler?.["ziel"] && (
|
||||||
|
<p className="text-danger -mt-2 text-[13px]">{zustand.feldfehler["ziel"]}</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Einschränkungen in authorized_keys (optional)"
|
||||||
|
name="optionen"
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder='restrict,from="10.0.0.12"'
|
||||||
|
hinweis='z. B. restrict, from="IP", command="…", no-pty, no-port-forwarding'
|
||||||
|
/>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||||
|
export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||||
|
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||||
|
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||||
|
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||||
|
ENTFERNEN: { text: "wird entfernt", klasse: "text-accent", punkt: "○" },
|
||||||
|
ENTFERNT: { text: "entfernt", klasse: "text-muted", punkt: "○" },
|
||||||
|
FEHLGESCHLAGEN: { text: "fehlgeschlagen", klasse: "text-danger", punkt: "●" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||||
|
const anzeige = ZUSTAND_ANZEIGE[zustand];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||||
|
anzeige.klasse,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{anzeige.punkt}</span>
|
||||||
|
{anzeige.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
KeySquare,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Network,
|
Network,
|
||||||
@@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [
|
|||||||
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
||||||
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
||||||
{ href: "/personen", titel: "Personen", icon: Users },
|
{ href: "/personen", titel: "Personen", icon: Users },
|
||||||
|
// Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen.
|
||||||
|
{ href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare },
|
||||||
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
||||||
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import {
|
||||||
|
eigenenKeyEntfernenAktion,
|
||||||
|
eigenenKeyHinterlegenAktion,
|
||||||
|
type HinterlegenZustand,
|
||||||
|
} from "@/server/actions/meine-schluessel";
|
||||||
|
|
||||||
|
/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */
|
||||||
|
export function KeyHinterlegen() {
|
||||||
|
const formular = useRef<HTMLFormElement>(null);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||||
|
eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise<HinterlegenZustand>,
|
||||||
|
);
|
||||||
|
const erfolg = (zustand as HinterlegenZustand).erfolg;
|
||||||
|
useEffect(() => {
|
||||||
|
if (erfolg) formular.current?.reset();
|
||||||
|
}, [erfolg]);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Public Key hinterlegen"
|
||||||
|
untertitel="Eine Zeile im OpenSSH-Format, z. B. aus ~/.ssh/id_ed25519.pub"
|
||||||
|
>
|
||||||
|
<form ref={formular} onSubmit={absenden} noValidate className="flex flex-col gap-4">
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{erfolg && (
|
||||||
|
<p role="status" className="text-ok text-[14px] break-all">
|
||||||
|
{erfolg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Bezeichnung"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Laptop, Workstation …"
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key
|
||||||
|
behalten Sie bei sich – sshkv fragt nie danach.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={laeuft}
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
{laeuft ? "Speichere …" : "Hinterlegen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */
|
||||||
|
export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(eigenenKeyEntfernenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
klein
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
aria-label={`${name} entfernen`}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby={`entfernen-${schluesselId}`}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={schluesselId} />
|
||||||
|
<h2 id={`entfernen-${schluesselId}`} className="text-[17px] font-semibold">
|
||||||
|
„{name}“ entfernen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server
|
||||||
|
ändert sich etwas.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Entferne …" : "Entfernen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
import { useActionState, useRef } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import {
|
||||||
|
personAnlegenAktion,
|
||||||
|
personArchivierenAktion,
|
||||||
|
personBearbeitenAktion,
|
||||||
|
} from "@/server/actions/personen";
|
||||||
|
|
||||||
|
export type PersonFormularVorgabe = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
organisation: string;
|
||||||
|
notiz: string;
|
||||||
|
benutzerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEERE_PERSON: PersonFormularVorgabe = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
organisation: "",
|
||||||
|
notiz: "",
|
||||||
|
benutzerId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */
|
||||||
|
export function PersonFormular({
|
||||||
|
vorgabe,
|
||||||
|
konten,
|
||||||
|
abbrechenZiel,
|
||||||
|
}: {
|
||||||
|
vorgabe: PersonFormularVorgabe;
|
||||||
|
konten: { id: string; name: string; email: string | null }[];
|
||||||
|
abbrechenZiel: string;
|
||||||
|
}) {
|
||||||
|
const bearbeiten = Boolean(vorgabe.id);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||||
|
bearbeiten ? personBearbeitenAktion : personAnlegenAktion,
|
||||||
|
);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Karte titel="Person">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="E-Mail (optional)"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={vorgabe.email}
|
||||||
|
maxLength={254}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["email"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Organisation (optional)"
|
||||||
|
name="organisation"
|
||||||
|
defaultValue={vorgabe.organisation}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Dienstleister"
|
||||||
|
fehler={f["organisation"]}
|
||||||
|
/>
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Verknüpftes Konto (optional)"
|
||||||
|
name="benutzerId"
|
||||||
|
defaultValue={vorgabe.benutzerId}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
>
|
||||||
|
<option value="">keines</option>
|
||||||
|
{konten.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.email ? `${k.name} (${k.email})` : k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{f["benutzerId"] && <p className="text-danger text-[13px] sm:col-span-2">{f["benutzerId"]}</p>}
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Notiz (optional)"
|
||||||
|
name="notiz"
|
||||||
|
defaultValue={vorgabe.notiz}
|
||||||
|
maxLength={1000}
|
||||||
|
fehler={f["notiz"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Person anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archivieren mit Rueckfrage, nativer `<dialog>` wie bei Hosts (#008). */
|
||||||
|
export function PersonArchivieren({ personId, name }: { personId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(personArchivierenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Person archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="person-archivieren-titel"
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={personId} />
|
||||||
|
<h2 id="person-archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
{name} archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre
|
||||||
|
Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Button, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { warten } from "@/components/vorgang/warten";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import {
|
||||||
|
rotationAltWiderrufenAktion,
|
||||||
|
rotationStartenAktion,
|
||||||
|
rotationUebernehmenAktion,
|
||||||
|
} from "@/server/actions/rotation";
|
||||||
|
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { OffeneEntfernung } from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
|
||||||
|
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
|
||||||
|
* derselben Stelle weiter.
|
||||||
|
*/
|
||||||
|
const SCHRITTE = [
|
||||||
|
"Neuer Schlüssel",
|
||||||
|
"Zuweisungen übernehmen",
|
||||||
|
"Dry-Run & Ausrollen",
|
||||||
|
"Verbindungstests",
|
||||||
|
"Alten widerrufen",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RotationsTest = {
|
||||||
|
id: string;
|
||||||
|
ziel: string;
|
||||||
|
ausgerollt: boolean;
|
||||||
|
status: string | null;
|
||||||
|
fehler: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RotationsDaten = {
|
||||||
|
alt: { id: string; name: string };
|
||||||
|
neu: { id: string; name: string } | null;
|
||||||
|
schritt: 0 | 1 | 2 | 3 | 4 | 5;
|
||||||
|
neuOffen: number;
|
||||||
|
tests: RotationsTest[];
|
||||||
|
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||||
|
widerruf: OffeneEntfernung[] | null;
|
||||||
|
darfPrivat: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [laeuft, setLaeuft] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const { alt, neu, schritt } = daten;
|
||||||
|
|
||||||
|
async function kette(arbeit: () => Promise<string | undefined>) {
|
||||||
|
setFehler(undefined);
|
||||||
|
try {
|
||||||
|
const meldung = await arbeit();
|
||||||
|
if (meldung) setFehler(meldung);
|
||||||
|
} catch (f) {
|
||||||
|
setFehler((f as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(undefined);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorbereiten = () =>
|
||||||
|
kette(async () => {
|
||||||
|
if (!neu) {
|
||||||
|
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
|
||||||
|
const antwort = await rotationStartenAktion(alt.id);
|
||||||
|
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||||
|
const ende = await warten(antwort.vorgangId);
|
||||||
|
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
setLaeuft("Zuweisungen werden übernommen …");
|
||||||
|
return (await rotationUebernehmenAktion(alt.id)).fehler;
|
||||||
|
});
|
||||||
|
|
||||||
|
const testen = () =>
|
||||||
|
kette(async () => {
|
||||||
|
for (const test of daten.tests) {
|
||||||
|
setLaeuft(`Teste ${test.ziel} …`);
|
||||||
|
const antwort = await verbindungTestenAktion(test.id);
|
||||||
|
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||||
|
await warten(antwort.vorgangId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const altWiderrufen = () =>
|
||||||
|
kette(async () => {
|
||||||
|
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
|
||||||
|
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = (
|
||||||
|
<>
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
{laeuft && (
|
||||||
|
<p role="status" className="text-muted text-[14px]">
|
||||||
|
{laeuft}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||||
|
|
||||||
|
{schritt <= 1 && (
|
||||||
|
<Karte
|
||||||
|
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
|
||||||
|
untertitel="Noch ändert sich auf keinem Host etwas."
|
||||||
|
>
|
||||||
|
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
|
||||||
|
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
|
||||||
|
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
|
||||||
|
<li>
|
||||||
|
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
|
||||||
|
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
|
||||||
|
</li>
|
||||||
|
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
|
||||||
|
</ul>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void vorbereiten()}
|
||||||
|
>
|
||||||
|
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 2 && neu && (
|
||||||
|
<Karte
|
||||||
|
titel="Neuen Schlüssel ausrollen"
|
||||||
|
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
|
||||||
|
>
|
||||||
|
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 3 && (
|
||||||
|
<Karte
|
||||||
|
titel="Verbindungen mit dem neuen Schlüssel testen"
|
||||||
|
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
|
||||||
|
>
|
||||||
|
<ul className="mb-4 flex flex-col gap-2">
|
||||||
|
{daten.tests.map((t) => (
|
||||||
|
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
|
||||||
|
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
|
||||||
|
{t.status === "ERLEDIGT" ? (
|
||||||
|
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
|
||||||
|
) : t.status === "FEHLGESCHLAGEN" ? (
|
||||||
|
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
|
||||||
|
) : (
|
||||||
|
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
|
||||||
|
)}
|
||||||
|
{t.ziel}
|
||||||
|
</span>
|
||||||
|
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<PlugZap className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void testen()}
|
||||||
|
>
|
||||||
|
Verbindungen testen
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && !daten.widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Alten Schlüssel entfernen"
|
||||||
|
untertitel={
|
||||||
|
daten.tests.length > 0
|
||||||
|
? "Alle Verbindungstests sind grün."
|
||||||
|
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted mb-4 text-[14px]">
|
||||||
|
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
|
||||||
|
folgt). Danach gilt er als rotiert.
|
||||||
|
</p>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Ban className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void altWiderrufen()}
|
||||||
|
>
|
||||||
|
Alten Schlüssel widerrufen
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && daten.widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Alten Schlüssel entfernen"
|
||||||
|
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[alt.id]}
|
||||||
|
offen={daten.widerruf}
|
||||||
|
zielkonten={[]}
|
||||||
|
vorauswahl={[]}
|
||||||
|
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||||
|
darfPrivat={daten.darfPrivat}
|
||||||
|
ohneZielkonten
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 5 && neu && (
|
||||||
|
<Karte titel="Rotation abgeschlossen">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
|
||||||
|
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
|
||||||
|
{neu.name}
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
ablaufZustand,
|
||||||
|
KATEGORIE_TEXT,
|
||||||
|
STATUS_TEXT,
|
||||||
|
typAnzeige,
|
||||||
|
type Kategorie,
|
||||||
|
type Status,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
|
||||||
|
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
|
||||||
|
|
||||||
|
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
|
||||||
|
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
|
||||||
|
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
|
||||||
|
export function StatusBadge({ status }: { status: Status }) {
|
||||||
|
if (status === "AKTIV") return null;
|
||||||
|
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
|
||||||
|
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
|
||||||
|
*/
|
||||||
|
export function AblaufBadge({
|
||||||
|
ablaufAm,
|
||||||
|
jetzt,
|
||||||
|
lang = false,
|
||||||
|
}: {
|
||||||
|
ablaufAm: Date | null;
|
||||||
|
jetzt: Date;
|
||||||
|
lang?: boolean;
|
||||||
|
}) {
|
||||||
|
const zustand = ablaufZustand(ablaufAm, jetzt);
|
||||||
|
if (zustand.art === "keiner" || !ablaufAm) return null;
|
||||||
|
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
|
||||||
|
if (zustand.art === "abgelaufen") {
|
||||||
|
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
|
||||||
|
}
|
||||||
|
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
|
||||||
|
if (zustand.art === "bald") {
|
||||||
|
return (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{lang ? (
|
||||||
|
<span>
|
||||||
|
läuft ab am {zeit} · {in_}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>läuft ab {in_}</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lang ? (
|
||||||
|
<Badge variante="neutral" className="text-muted">
|
||||||
|
<span>läuft ab am {zeit}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
|
||||||
|
import type { Auswahl } from "./SchluesselNeu";
|
||||||
|
|
||||||
|
export type SchluesselFormularWerte = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags: string[];
|
||||||
|
/** JJJJ-MM-TT oder leer */
|
||||||
|
ablaufAm: string;
|
||||||
|
personId: string;
|
||||||
|
vorlageId: string;
|
||||||
|
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||||
|
statusAenderbar: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
|
||||||
|
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
|
||||||
|
*/
|
||||||
|
export function SchluesselBearbeiten({
|
||||||
|
werte,
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
}: {
|
||||||
|
werte: SchluesselFormularWerte;
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
}) {
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
<input type="hidden" name="id" value={werte.id} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Karte titel="Metadaten">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
defaultValue={werte.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
defaultValue={werte.ablaufAm}
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
defaultValue={werte.tags.join(", ")}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
{werte.statusAenderbar && (
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
|
||||||
|
<option value="AKTIV">aktiv</option>
|
||||||
|
<option value="ABGELAUFEN">abgelaufen</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, KeyRound, Sparkles } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import {
|
||||||
|
Ankreuzfeld,
|
||||||
|
Auswahlliste,
|
||||||
|
Button,
|
||||||
|
Feld,
|
||||||
|
Feldgruppe,
|
||||||
|
Formularfehler,
|
||||||
|
Karte,
|
||||||
|
Textbereich,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import {
|
||||||
|
paarImportierenAktion,
|
||||||
|
publicKeyImportierenAktion,
|
||||||
|
schluesselErzeugenAktion,
|
||||||
|
type SchluesselAntwort,
|
||||||
|
} from "@/server/actions/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
|
||||||
|
*
|
||||||
|
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
|
||||||
|
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
|
||||||
|
* Formular, bis sie abgeschickt sind.
|
||||||
|
*/
|
||||||
|
type Modus = "erzeugen" | "public" | "paar";
|
||||||
|
|
||||||
|
export type Auswahl = { id: string; name: string; zusatz?: string | null };
|
||||||
|
|
||||||
|
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
|
||||||
|
|
||||||
|
function Umschalter({
|
||||||
|
wert,
|
||||||
|
setzen,
|
||||||
|
optionen,
|
||||||
|
beschriftung,
|
||||||
|
}: {
|
||||||
|
wert: string;
|
||||||
|
setzen: (wert: string) => void;
|
||||||
|
optionen: { wert: string; text: string; icon?: ReactNode }[];
|
||||||
|
beschriftung: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={beschriftung}
|
||||||
|
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
|
||||||
|
>
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={wert === o.wert}
|
||||||
|
onClick={() => setzen(o.wert)}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
|
||||||
|
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.icon}
|
||||||
|
{o.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchluesselNeu({
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
vorgabePersonId,
|
||||||
|
}: {
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
vorgabePersonId?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
|
||||||
|
const [importArt, setImportArt] = useState<"public" | "paar">("public");
|
||||||
|
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
|
||||||
|
|
||||||
|
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
|
||||||
|
const [sendet, senden] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
|
||||||
|
|
||||||
|
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
|
||||||
|
const [privateKey, setPrivateKey] = useState("");
|
||||||
|
const [passphrase, setPassphrase] = useState("");
|
||||||
|
const [wiederholung, setWiederholung] = useState("");
|
||||||
|
|
||||||
|
const ergebnisId =
|
||||||
|
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
|
||||||
|
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
|
||||||
|
}, [ergebnisId, router]);
|
||||||
|
|
||||||
|
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
|
||||||
|
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
|
||||||
|
const f = antwort.feldfehler ?? {};
|
||||||
|
const fehler =
|
||||||
|
antwort.fehler ??
|
||||||
|
abfrageFehler ??
|
||||||
|
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
|
||||||
|
|
||||||
|
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const daten = new FormData(ereignis.currentTarget);
|
||||||
|
const aktion =
|
||||||
|
modus === "erzeugen"
|
||||||
|
? schluesselErzeugenAktion
|
||||||
|
: modus === "paar"
|
||||||
|
? paarImportierenAktion
|
||||||
|
: publicKeyImportierenAktion;
|
||||||
|
senden(async () => {
|
||||||
|
setAntwort({});
|
||||||
|
const neu = await aktion({}, daten);
|
||||||
|
if (neu.vorgangId || neu.schluesselId) {
|
||||||
|
setPrivateKey("");
|
||||||
|
setPassphrase("");
|
||||||
|
setWiederholung("");
|
||||||
|
}
|
||||||
|
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
|
||||||
|
setAntwort(neu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const knopfText = beschaeftigt
|
||||||
|
? modus === "erzeugen"
|
||||||
|
? "Erzeuge …"
|
||||||
|
: "Importiere …"
|
||||||
|
: modus === "erzeugen"
|
||||||
|
? "Schlüssel erzeugen"
|
||||||
|
: "Schlüssel importieren";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Art"
|
||||||
|
wert={art}
|
||||||
|
setzen={(w) => {
|
||||||
|
setArt(w as typeof art);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
|
||||||
|
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{art === "importieren" && (
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Was importieren?"
|
||||||
|
wert={importArt}
|
||||||
|
setzen={(w) => {
|
||||||
|
setImportArt(w as typeof importArt);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "public", text: "Nur Public Key" },
|
||||||
|
{ wert: "paar", text: "Schlüsselpaar" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Zweck">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Backup srv-backup01 → srv-pve01"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kurzname (optional)"
|
||||||
|
name="slug"
|
||||||
|
mono
|
||||||
|
maxLength={64}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="wird aus dem Zweck gebildet"
|
||||||
|
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
|
||||||
|
fehler={f["slug"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kommentar (optional)"
|
||||||
|
name="kommentar"
|
||||||
|
mono
|
||||||
|
maxLength={200}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
|
||||||
|
hinweis="Steht hinter dem Public Key in authorized_keys."
|
||||||
|
fehler={f["kommentar"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{modus === "erzeugen" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe legende="Typ" fehler={f["typ"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="ED25519"
|
||||||
|
defaultChecked
|
||||||
|
beschriftung={<span className="font-mono">ed25519</span>}
|
||||||
|
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="RSA4096"
|
||||||
|
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||||
|
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
|
||||||
|
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (optional)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase wiederholen"
|
||||||
|
name="passphraseWiederholung"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={wiederholung}
|
||||||
|
onChange={(e) => setWiederholung(e.target.value)}
|
||||||
|
disabled={!passphrase}
|
||||||
|
fehler={f["passphraseWiederholung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "public" && (
|
||||||
|
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="DIENST"
|
||||||
|
defaultChecked={!vorgabePersonId}
|
||||||
|
beschriftung="Dienst"
|
||||||
|
erklaerung="Ein Programm oder Host meldet sich damit an."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="PERSON"
|
||||||
|
defaultChecked={Boolean(vorgabePersonId)}
|
||||||
|
beschriftung="Person"
|
||||||
|
erklaerung="Ein Mensch meldet sich damit an."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "paar" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüsselpaar"
|
||||||
|
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Private Key"
|
||||||
|
name="privateKey"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
|
||||||
|
fehler={f["privateKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (nur falls der Key eine hat)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
className="sm:max-w-md"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
|
||||||
|
nie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte titel="Einordnung">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
|
||||||
|
name="personId"
|
||||||
|
defaultValue={vorgabePersonId ?? ""}
|
||||||
|
>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum (optional)"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="backup, pve"
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={beschaeftigt}
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
>
|
||||||
|
{knopfText}
|
||||||
|
</Button>
|
||||||
|
<Button href="/schluessel">Abbrechen</Button>
|
||||||
|
<p role="status" className="text-muted text-[13px]">
|
||||||
|
{vorgangLaeuft && "Der Worker arbeitet …"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||