diff --git a/docs/superpowers/specs/2026-08-03-mitarbeiterverwaltung-design.md b/docs/superpowers/specs/2026-08-03-mitarbeiterverwaltung-design.md new file mode 100644 index 0000000..d486158 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-mitarbeiterverwaltung-design.md @@ -0,0 +1,380 @@ +# Mitarbeiterverwaltung im Userpanel — Entwurf + +**Stand:** 2026-08-03 +**Projekt A von dreien** (B = Frühwarnsystem, fertig; C = Postfächer in der Konsole, offen) + +## Worum es geht + +Der Inhaber soll aus dem Userpanel heraus Mitarbeiter anlegen, einladen und +verwalten. Mitarbeiter melden sich in der **Nextcloud-Instanz** an, nicht im +Portal — im Userpanel hat ein Mitarbeiter nichts zu suchen. + +## Ausgangslage: es ist eine Attrappe + +Die Sitzverwaltung im Portal ist vollständig gebaut — Tabelle, Rollen, +Sitzplatzgrenze aus dem Vertrag, Bestätigungs-Modals nach R23, Sperren gegen +das Entfernen des letzten Inhabers. Nur: + +**Keine einzige dieser Aktionen erreicht je die Nextcloud.** + +`app/Livewire/Users.php` schreibt Zeilen in `seats` und sonst nichts. Einladen +legt keinen Benutzer an. Aussetzen sperrt niemanden aus. Entziehen löscht eine +Zeile bei uns, während der Mitarbeiter weiterarbeitet. `resend()` trägt seit +jeher den Kommentar `// Invite delivery is mocked for now.` — es wird nie eine +Mail verschickt. + +Das Modul wird verkauft. Es tut nichts. + +### Der zweite Fund: die Kunden-Nextcloud kann gar nicht mailen + +Weder die Bereitstellung noch `deploy/bootstrap/assets/docker-compose.yml` +setzen je einen Mailversand im Gast. Damit verschickt die Nextcloud eines +Kunden **überhaupt keine Mail** — keine Freigabe-Benachrichtigung, keine +Aktivitätsmeldung, kein „Passwort vergessen". Stumm, ohne Fehlermeldung. + +Das ist die Voraussetzung für alles Weitere und wird deshalb zuerst gebaut. + +## Die tragende Entscheidung: niemand kennt ein fremdes Passwort + +Der Mitarbeiter bekommt sein Passwort **von seiner Nextcloud**, nicht von uns: + +``` +occ user:add --generate-password --email --display-name +``` + +Nextcloud erzeugt ein Passwort, das **niemand** zu sehen bekommt, und schickt +dem Mitarbeiter eine Willkommensmail mit einem zeitlich begrenzten Link, an +dem er sein eigenes setzt. Weder der Inhaber noch der Betreiber noch CluPilot +kennen es je. + +Damit erledigen sich drei Fragen auf einmal: + +- **Passwort vergessen** — der Mitarbeiter benutzt „Passwort vergessen" in der + Nextcloud; der Inhaber hat zusätzlich einen Knopf „Erneut einladen". +- **Kurzzeitig gültig** — die Gültigkeit des Links regelt Nextcloud selbst. +- **Wie gebe ich es weiter** — gar nicht. Es gibt nichts weiterzugeben. + +Verworfen wurde: ein von CluPilot erzeugtes Einmalpasswort mit Wechselzwang +beim ersten Login. **Nextcloud kann das nicht.** Eine Ablauffrist für +Passwörter gibt es nur instanzweit (`password_policy`), sie träfe also den +Inhaber und jeden anderen gleichermaßen. Einen Wechselzwang für einen +einzelnen Benutzer gibt es nicht. + +--- + +## Teil 1: Mailversand in der Kunden-Nextcloud + +### Eigene Absenderdomain + +Die Kunden-Nextcloud verschickt über **`clupilot.cloud`**, nicht über +`clupilot.com`. + +Der Grund ist Absenderruf: über `clupilot.com` laufen Rechnungen, +Bestellbestätigungen und Sicherheitsmeldungen an die Kunden. Wenn die Nextcloud +eines Kunden Freigabe-Benachrichtigungen am laufenden Band verschickt und +dabei in Sperrlisten gerät, darf das die Domain nicht mitreißen, über die das +Geschäft läuft. Zwei Domains, zwei Rufe, ein Ausfall bleibt einer. + +### Was eingetragen wird + +Ein neuer Bereitstellungsschritt schreibt in jeden Gast: + +| Schlüssel | Wert | +|---|---| +| `mail_smtpmode` | `smtp` | +| `mail_smtphost` | aus dem CluPilot-Postfach | +| `mail_smtpport` | aus dem CluPilot-Postfach | +| `mail_smtpsecure` | aus `App\Services\Mail\MailTlsPolicy` des Postfachs | +| `mail_smtpauth` | `true` | +| `mail_smtpname` | Benutzername des Postfachs | +| `mail_smtppassword` | Passwort des Postfachs | +| `mail_from_address` | `noreply` | +| `mail_domain` | `clupilot.cloud` | +| `mail_sendmailmode` | entfällt | + +Der Anzeigename ist der Cloud-Name des Kunden, damit eine Mail aus *seiner* +Cloud kommt und nicht aus einem Produkt, das er nicht kennt. + +### Das Passwort darf nicht in die Prozessliste + +`occ config:system:set mail_smtppassword --value=…` würde das Passwort als +Befehlszeilen-Argument in den Gast tragen. Jeder, der dort `ps` ausführen kann, +liest es mit — und das ist auf einer Kundenmaschine niemand, dem wir das +SMTP-Passwort unserer Versandinfrastruktur anvertrauen wollen. + +Deshalb geht die ganze Mailkonfiguration als **JSON über die Standardeingabe** +hinein: + +``` +occ config:import < /dev/stdin +``` + +Die Zeile wird in `App\Support\NextcloudOcc` gebaut, wie jeder andere +occ-Aufruf auch. Nichts in `app/` schreibt `docker compose exec` selbst — das +ist eine bestehende, testerzwungene Regel dieses Projekts. + +### Bestand nachrüsten + +Ein Artisan-Befehl `clupilot:configure-instance-mail` fährt denselben Schritt +über alle aktiven Instanzen. Niemand muss neu bereitgestellt werden. Der +Befehl ist wiederholbar: dieselben Werte zweimal zu schreiben ändert nichts. + +### Neues CluPilot-Postfach + +Ein `Mailbox`-Datensatz mit dem Schlüssel `instance-relay` und der Adresse +`noreply@clupilot.cloud`. Er erscheint in der Postfachverwaltung der Konsole +wie die anderen auch. + +**Nicht** in `MailCatalogue`: die Einladungsmail ist keine CluPilot-Mail mehr. +Sie entsteht in der Nextcloud des Kunden. Auf der Seite „Wegwahl je Mailart" +taucht sie deshalb bewusst nicht auf — was dort steht, verschickt CluPilot +selbst. + +--- + +## Teil 2: Anlegen ist nicht Einladen + +Zwei getrennte Vorgänge, ausdrücklich so gewünscht: + +| Vorgang | Wirkung bei uns | Wirkung in der Nextcloud | +|---|---|---| +| **Anlegen** | Sitz mit Name, Mail, Rolle | keine | +| **Einladen** | Zustand → „wird eingerichtet" | `user:add --generate-password --email` | +| **Erneut einladen** | Zustand bleibt | `user:welcome --reset-password` | + +Ein Inhaber kann also sein Team vorbereiten und die Einladungen später +verschicken — etwa alle am ersten Arbeitstag. + +### Der Nextcloud-Benutzername + +`nc_username` wird beim **ersten** Einladen gesetzt und danach **nie wieder +geändert**. Nextcloud kann Benutzer nicht umbenennen; ein Sitz, dessen +Mailadresse sich später ändert, behält seinen Anmeldenamen. + +Als Benutzername dient die Mailadresse. Nextcloud lässt `@`, `.`, `-` und `_` +in Benutzerkennungen zu, und die Adresse ist innerhalb eines Kunden ohnehin +eindeutig — das erzwingt die bestehende Prüfung in `Users::invite()`. + +### Der Inhaber-Sitz ist der bestehende Admin + +Der Sitz mit der Rolle `owner` wird **nicht** neu angelegt. Sein +`nc_username` ist `Instance::nc_admin_ref` — das Konto, das die Bereitstellung +in `CreateCustomerAdmin` erzeugt hat. Ein zweites Admin-Konto für dieselbe +Person wäre ein Konto zu viel. + +--- + +## Teil 3: Rollen sind Nextcloud-Gruppen + +Vier Rollen bleiben. Jede ist eine Gruppe im Gast, damit weitere später eine +Zeile sind und kein Umbau. + +| Rolle | Nextcloud-Gruppe | Was sie bewirkt | +|---|---|---| +| `owner` | `admin` | Vollzugriff; ist das bestehende Admin-Konto | +| `admin` | `admin` | Vollzugriff, kann Benutzer verwalten | +| `member` | `mitarbeiter` | normaler Benutzer | +| `readonly` | `nur-lesen` | Speicherplatz `0 B` — kann nichts hochladen | + +Rollenwechsel: `group:adduser` und `group:removeuser`. Wird `readonly` +**gesetzt**, geht der Speicherplatz auf `0 B`; wird sie **verlassen**, geht er +auf `default` zurück — nicht auf einen festen Wert, denn die Vorgabe der +Instanz hängt am gekauften Paket (`ApplyStorageQuota` setzt sie). + +### Was `readonly` ehrlicherweise NICHT ist + +Nextcloud kennt keine Rolle „nur lesen". Ein Speicherplatz von `0 B` +verhindert das **Hochladen und Anlegen** — mehr nicht. Was jemandem mit +Bearbeitungsrecht freigegeben wurde, kann er weiterhin ändern und löschen. + +Die Oberfläche muss das genau so sagen: **„Kann nichts hochladen oder anlegen. +Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten."** Eine +Rolle, die mehr verspricht als sie hält, ist schlimmer als keine. + +### Offene technische Unsicherheit + +Ob Nextcloud den Speicherplatzwert `0 B` als „null Bytes" und nicht als +„unbegrenzt" auslegt, ist aus der Dokumentation nicht zu belegen. Das ist +**kein Annahmerecht**: der Umsetzungsplan bekommt einen eigenen Nachweisschritt +gegen eine echte Instanz, bevor `readonly` als fertig gilt. Fällt der Nachweis +negativ aus, ist die Rückfallebene die Gruppe ohne Speicherplatzgrenze plus +ein deutlicher Hinweis in der Oberfläche, dass die Beschränkung über +Freigaberechte läuft. + +--- + +## Teil 4: Aussetzen, reaktivieren, entziehen + +| Knopf | Nextcloud | +|---|---| +| Aussetzen | `user:disable` **und** `user:auth-tokens:delete` | +| Reaktivieren | `user:enable` | +| Entziehen | `user:disable`, Sitz auf `revoked` | + +`user:disable` allein beendet laufende Sitzungen erst binnen fünf Minuten. +`user:auth-tokens:delete` wirft sie sofort hinaus — bei einem Mitarbeiter, der +gerade gegangen ist, sind fünf Minuten fünf zu viel. + +### Entziehen löscht nichts + +**Kein Nextcloud-Benutzer wird je gelöscht, und keine Datei.** Entziehen ist +Sperren mit einem anderen Namen. Der Sitz bleibt als Zeile stehen, der Zugang +ist zu, die Arbeit des Mitarbeiters bleibt dort, wo sein Team sie braucht. + +Das ist ausdrücklich entschieden und nicht bloß der bequemere Weg: ein Fehlgriff +im Userpanel darf nicht die Arbeit eines Menschen vernichten. Wer wirklich +löschen will, tut das in der Nextcloud selbst, wo Nextcloud danach fragt, was +mit den Dateien geschehen soll. + +Der bestehende `revoke()`, der die Zeile mit `$seat->delete()` entfernt, wird +entsprechend umgebaut. Die Sitzplatzzählung (`usedSeats()`) zählt schon heute +`status != 'revoked'`, passt also unverändert. + +--- + +## Teil 5: Ratelimit + +Zwei Grenzen über Laravels `RateLimiter`: + +- **10 Zugangsmails je Kunde und Stunde** +- **3 je Sitz und Stunde** + +Betroffen sind „Einladen" und „Erneut einladen" — beides löst eine Mail aus. + +Wer dagegenläuft, bekommt **„Zu viele Einladungen — in X Minuten wieder +möglich"** zu lesen, mit der echten Restzeit. Eine stumme Verweigerung wäre +schlimmer als die Grenze selbst. + +--- + +## Teil 6: Der Sitz zeigt, was wirklich ist + +Jede Nextcloud-Änderung läuft über die **Bereitstellungs-Warteschlange** — +nur dieser Arbeiter hat den Tunnel und die Proxmox-Zugangsdaten. Die Seite +selbst erreicht keinen Gast, und sie darf es auch nicht versuchen. + +Daraus folgt: **der Knopf wirkt nicht sofort.** Ohne sichtbaren Zustand drückt +der Inhaber wieder und wieder, weil nichts passiert — genau die Beschwerde, +die dieses Projekt schon zweimal hatte. + +Deshalb führt der Sitz zwei Angaben nebeneinander: + +- **`status`** — was der Inhaber WILL (`invited`, `active`, `suspended`, + `revoked`). Ändert sich sofort beim Klick. +- **`nc_state`** — was in der Nextcloud WIRKLICH ist (`none`, `pending`, + `synced`, `failed`). Ändert sich, wenn der Auftrag durch ist. + +Absicht und Wirklichkeit getrennt zu führen ist der einzige Weg, „fehlgeschlagen" +überhaupt sagen zu können. Ein einzelnes Feld müsste lügen. + +Dazu `nc_error` (Klartextgrund) und `nc_synced_at`. + +### In der Oberfläche + +| `nc_state` | Anzeige | +|---|---| +| `none` | „angelegt — noch nicht eingeladen" | +| `pending` | „wird eingerichtet …" | +| `synced` | der `status` selbst (aktiv / gesperrt / entzogen) | +| `failed` | **„fehlgeschlagen"** mit Grund und Knopf „Nochmal versuchen" | + +--- + +## Fehlerbehandlung + +Der Auftrag schlägt fehl, wenn die Instanz nicht `active` ist, kein Host +hängt, `vmid` fehlt, der Gastagent nicht antwortet oder occ einen Fehlercode +liefert. In jedem Fall: + +1. `nc_state = failed`, `nc_error` = ein Grund in Klartext, kein Stacktrace. +2. `Log::warning` mit Sitz-UUID und Instanz-UUID, **nie** mit Zugangsdaten. +3. Der Auftrag wirft nicht weiter — ein hängender Gast darf den + Bereitstellungs-Arbeiter nicht mitreißen, auf dem die bezahlte + Kundenbereitstellung läuft. + +`tries = 1`, wie bei `IssueInstanceAdminAccess`: ein blinder Wiederholungslauf +gegen einen nicht erreichbaren Gast bringt nichts, und der Inhaber hat einen +Knopf dafür. + +### Wiederholbarkeit + +Einladen prüft zuerst mit `user:info`, ob der Benutzer schon existiert — genau +wie `CreateCustomerAdmin` es tut. Ein zweiter Lauf nach einem Absturz legt +keinen zweiten Benutzer an, sondern schickt die Willkommensmail erneut. + +--- + +## Datenmodell + +Wanderung an `seats`: + +| Spalte | Typ | Zweck | +|---|---|---| +| `nc_username` | `string`, nullable | einmalig gesetzt, nie geändert | +| `nc_state` | `string`, Vorgabe `none` | `none`/`pending`/`synced`/`failed` | +| `nc_error` | `text`, nullable | Klartextgrund des letzten Fehlschlags | +| `nc_synced_at` | `timestamp`, nullable | wann zuletzt erfolgreich | + +Bestehende Sitze bekommen `nc_state = 'none'`. Sie sind damit ehrlich +beschrieben: angelegt, nie eingeladen — was der Wahrheit entspricht, denn +eingeladen hat sie nie jemand. + +**Eine Ausnahme, und sie ist wichtig:** der `owner`-Sitz eines Kunden, dessen +Instanz ein `nc_admin_ref` trägt, bekommt in derselben Wanderung +`nc_username = nc_admin_ref` und `nc_state = 'synced'`. Dieser Zugang existiert +in der Nextcloud tatsächlich — die Bereitstellung hat ihn angelegt. Stünde er +auf `none`, böte das Userpanel dem Inhaber an, sich selbst einzuladen, und der +Auftrag träfe auf einen Benutzer, den es längst gibt. + +--- + +## Sicherheitsgrenzen + +- **Jede Aktion prüft, dass der Sitz zum angemeldeten Kunden gehört** — auf der + Seite UND im Auftrag. Ein Modal ist ohne die Middleware der Seite erreichbar + (R20); dieselbe Regel, die schon beim Frühwarnsystem galt. +- **Der Auftrag prüft erneut**, dass die Instanz aktiv ist und dem Kunden + gehört. Eine geschlossene Instanz kann ihre VMID auf demselben Host + weiterverliehen haben — dieselbe Falle, die in + `IssueInstanceAdminAccess` schon beschrieben steht. +- **Der letzte Inhaber bleibt.** Die bestehenden Sperren gegen das Entfernen + oder Herabstufen des letzten `owner` bleiben unverändert. +- **Ein Inhaber kann sich nicht selbst aussperren.** Die bestehende Prüfung in + `suspend()` bleibt. +- **Keine Zugangsdaten ins Log**, in keiner Form. +- **Bestätigung im Modal** (R23) für Aussetzen und Entziehen, kein + Browser-Dialog. + +--- + +## Prüfung + +- **Einheit:** Befehlsbau in `NextcloudOcc` — dass das SMTP-Passwort in + **keinem** erzeugten Befehl als Argument steht. Strukturell geprüft, am + erzeugten Befehl, nicht am Quelltext. +- **Aufträge:** gegen `FakeProxmoxClient` — angelegt, eingeladen, gesperrt, + reaktiviert, Rolle gewechselt; und für jeden Zweig der Fehlschlag mit + `nc_state = failed`. +- **Wiederholbarkeit:** zweimal einladen legt keinen zweiten Benutzer an. +- **Seite:** ein fremder Sitz lässt sich nicht anfassen (403); die Zeile + erneuert sich nach jeder Aktion — die Prüfung über den **ganzen** Weg, + Modal-Ereignis eingeschlossen, nicht nur der direkte Methodenaufruf. +- **Ratelimit:** die elfte Einladung derselben Stunde wird abgewiesen, mit + Restzeit in der Meldung. +- **Löschverbot:** kein Auftrag im Repo darf `user:delete` enthalten — + testerzwungen, wie die anderen Regeln dieses Projekts. +- **Nachweis gegen echte Hardware:** Speicherplatz `0 B` verhindert das + Hochladen. Eigener Schritt im Plan. + +--- + +## Was nicht gebaut wird + +- **Slack-Anbindung.** Eigenes Projekt, kein Nebensatz hier. +- **Die `.cloud`-Domain als Marketing-Auftritt.** Für diesen Bau genügt sie als + Absenderdomain. +- **Endgültiges Löschen von Benutzern.** Siehe Teil 4. +- **Selbst erstellte Rollen.** Die vier genügen für den Anfang; die + Gruppen-Abbildung ist so gebaut, dass weitere später hinzukommen können. +- **Ein Portalkonto für Mitarbeiter.** Sie melden sich in der Nextcloud an. +- **Instanzweiter Passwortablauf.** Er träfe den Inhaber mit.