# 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 <mail> --display-name <name> <uid>
```

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:

Server und Zugangsdaten liegen in diesem Projekt an **zwei** Orten, und das ist
Absicht: der Server steht einmal in `app_settings` („All mailboxes live on one
host, so it sits once in app_settings — a provider change is then one card, not
five rows"), die Zugangsdaten je Absender am `Mailbox`-Datensatz.

| Schlüssel | Wert | Herkunft |
|---|---|---|
| `mail_smtpmode` | `smtp` | fest |
| `mail_smtphost` | z. B. `smtp.…` | `Settings::get('mail.host')` |
| `mail_smtpport` | z. B. `587` | `Settings::get('mail.port')` |
| `mail_smtpsecure` | `tls` / `ssl` / leer | `Settings::get('mail.encryption')` |
| `mail_smtpauth` | `true` / `false` | `Mailbox::$authenticates` |
| `mail_smtpname` | Benutzername | `Mailbox::smtpUsername()` |
| `mail_smtppassword` | Passwort | `Mailbox` (unter `SECRETS_KEY`) |
| `mail_from_address` | `noreply` | linker Teil der Postfachadresse |
| `mail_domain` | `clupilot.cloud` | rechter Teil der Postfachadresse |

Fehlt der Server (`mail.host` leer) oder das Postfach, wird **nichts**
geschrieben. Eine halb eingetragene Mailkonfiguration ist schlimmer als keine:
Nextcloud versucht dann zu senden und scheitert bei jedem einzelnen Versand
still.

**Aber der Schritt laesst die Bereitstellung weiterlaufen.** Er protokolliert
den Grund und geht weiter — er scheitert nicht.

Das ist kein Nachlassen, sondern dieselbe Abwaegung, die dieses Projekt bei der
Ueberwachung schon getroffen hat: eine Nebenfunktion darf keine bezahlte
Bestellung aufhalten. Der Mailserver dieses Produkts wird gerade erst
aufgesetzt; waere dieser Schritt zwingend, bliebe ab dem Ausrollen **jede neue
Bestellung** daran haengen. Der Kunde zahlt und bekommt keine Cloud, weil
Einladungsmails nicht eingerichtet sind — eine Funktion, die er vielleicht nie
benutzt.

Ohne Versand funktioniert die Cloud vollstaendig. Nur Einladungen und
Freigabe-Benachrichtigungen gehen nicht, und `clupilot:configure-instance-mail`
holt es nach, sobald der Server steht.

Nicht eingerichtet ist etwas anderes als kaputt: sobald Server und Postfach da
sind, bleibt ein Fehler beim SCHREIBEN ein Fehlschlag wie jeder andere.

Ein eigener **Anzeigename je Kunde** wird bewusst NICHT geschrieben. Ein
frueherer Entwurfsstand sah ihn vor; beim Bauen stellte sich heraus, dass
`instances` gar keine Spalte traegt, aus der er kommen koennte. Nextcloud
benutzt in diesem Fall den Namen aus seiner eigenen Gestaltung — den der Kunde
in seiner Cloud selbst setzen kann. Das ist der bessere Ort dafuer: es ist
seine Cloud, nicht unsere.

### Das Passwort ist auf der Kundenmaschine nicht zu verstecken

Der ursprüngliche Entwurf wollte das SMTP-Passwort an der Prozessliste
vorbeischmuggeln — erst über die Standardeingabe, dann über eine Variable, die
erst im Container eingesetzt wird. **Beides ist Theater, und zwar nachweislich.**

Nachgerechnet in der Umsetzung:

1. `ProxmoxClient::guestExec()` übergibt die **ganze** Befehlszeile als
   `['/bin/sh', '-c', …]` an den Gastagenten. Was immer darin steht, steht im
   Argv eines Prozesses auf der Kunden-VM. Debians `dash` — die Standard-Shell
   dieser Maschinen — behält diesen Elternprozess für die volle Dauer des
   Aufrufs; `bash` würde ihn wegoptimieren, `dash` tut es nicht.
2. Eine Variable, die die innere Shell im Container einsetzt, landet danach im
   Argv von `php occ` — die Expansion passiert **vor** dem `exec`.
3. Und wichtiger als beides: **Nextcloud legt `mail_smtppassword` anschließend
   im Klartext in `config/config.php` ab.** Wer im Container `/proc` lesen
   kann, liest auch diese Datei. Wer auf der VM Zugriff hat, ebenso.

Ein Versteck, das dieselbe Person nicht aussperrt, die den dauerhaften
Ablageort ohnehin lesen kann, ist kein Versteck. Der Wert geht deshalb als
gewöhnliches Argument über `NextcloudOcc::command()` hinein — wie jeder andere
occ-Aufruf dieses Projekts —, und der Schritt sagt in seinem Kopfkommentar
ausdrücklich, wo das Passwort überall sichtbar ist.

**Die Eingrenzung liegt woanders, und dort liegt sie wirklich:**

- Das Versandkonto kann **nur senden** — kein IMAP, kein Postfach, und
  ausschließlich auf der Absenderdomain der Instanzen.
- Der Versandport nimmt **nur die eigenen Hostadressen** an. Wer die
  Zugangsdaten aus einer VM zieht, kann sie von anderswo nicht benutzen.
- Der Mailserver kennt ein Sendelimit je Konto.

Das ist die ehrliche Rechnung: der Wert ist auf der Maschine des Kunden
lesbar, und was er damit anfangen kann, ist eng begrenzt.

### 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`. Dazu bei `readonly` der
Speicherplatz — und hier lauert eine Falle, die in diesem Repo bereits
aufgeschrieben steht.

### Die Speicherplatz-Falle

`ApplyStorageQuota` setzt bewusst `files default_quota`, den **instanzweiten
Vorgabewert**, und begründet im Kopfkommentar, warum kein Konto einen eigenen
bekommen darf:

> „An account with an explicit quota stops following the default, so writing one
> onto the admin account would freeze that account at today's figure and quietly
> exclude it from the next plan change."

Genau das täte ein Speicherplatz am `readonly`-Konto. Für `readonly` ist das
richtig — dieses Konto SOLL keinen Platz bekommen, egal was das Paket später
sagt. Aber beim **Verlassen** der Rolle genügt es nicht, irgendeinen Wert zu
setzen: der eigene Wert muss **weg**, damit das Konto der Vorgabe wieder folgt.

- `readonly` **gesetzt** → eigener Speicherplatz `0 B`
- `readonly` **verlassen** → eigenen Speicherplatz **löschen**, nicht
  überschreiben

Ein Konto, das mit einem festen Wert aus der Rolle herauskommt, wäre bei der
nächsten Paketänderung stumm ausgenommen — und niemand würde es merken, bis
der Kunde fragt, warum sein Mitarbeiter weniger Platz hat als bezahlt.

### 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.
