# Mitarbeiterverwaltung im Userpanel — Umsetzungsplan

> **Für agentische Arbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte benutzen Checkbox-Syntax (`- [ ]`).

**Ziel:** Aus der Attrappe im Portal wird echte Verwaltung — der Inhaber legt Mitarbeiter an, lädt sie ein, ändert Rollen, sperrt und reaktiviert, und jede dieser Handlungen erreicht die Nextcloud des Kunden.

**Architektur:** Die Seite fasst nie einen Gast an. Sie schreibt eine Absicht auf den Sitz und schickt einen Auftrag auf die `provisioning`-Warteschlange — die einzige, die Tunnel und Proxmox-Zugangsdaten hat. Der Auftrag spiegelt den Sitz nach Nextcloud und schreibt zurück, was wirklich passiert ist. Voraussetzung für alles ist ein Mailversand in der Kunden-Nextcloud, den es heute nicht gibt.

**Tech Stack:** Laravel 13.8, Livewire 3 (klassenbasiert), Pest, Proxmox-Gastagent (`guestExec`), Nextcloud `occ`, Redis-Warteschlangen.

**Entwurf:** `docs/superpowers/specs/2026-08-03-mitarbeiterverwaltung-design.md`

## Global Constraints

- **Nichts in `app/` schreibt `docker compose exec` selbst.** Jeder Gast-Befehl entsteht in `App\Support\NextcloudOcc`. Ein bestehender Test erzwingt das und nimmt nur diese eine Datei aus.
- **Kein Nextcloud-Benutzer wird je gelöscht.** `user:delete` darf in keiner Datei unter `app/` vorkommen — das wird in Aufgabe 7 testerzwungen.
- **Der Nextcloud-Benutzername wird einmal gesetzt und nie geändert.** Nextcloud kann Benutzer nicht umbenennen.
- **Aufträge werfen nicht weiter.** Ein nicht erreichbarer Gast darf den Bereitstellungs-Arbeiter nicht mitreißen, auf dem die bezahlte Kundenbereitstellung läuft. `tries = 1`.
- **Keine Zugangsdaten ins Log**, in keiner Form.
- **Absicht und Wirklichkeit stehen getrennt**: `seats.status` = was der Inhaber will, `seats.nc_state` = was in der Nextcloud ist.
- **R19:** jede Zeit, die ein Mensch liest, geht durch `->local()`.
- **R20:** Bearbeiten im Modal, nicht in der Zeile. **R23:** Bestätigen im Modal, nie `wire:confirm`. **R24:** Modal nie höher als der Bildschirm.
- **R22:** eine Prüfrunde, eine Fix-Runde, ein Re-Review. Danach wird geparkt.
- **Deutsch mit Umlauten**, in Kommentaren, Testnamen und Benutzertexten
  gleichermassen — so schreibt dieser Bestand (`HostFirewall.php`: 33
  Kommentarzeilen mit Umlauten; Testnamen wie `it('haelt eine Bridge und einen
  Bond fuer keine physische Karte')` tragen sie ebenso). Eine fruehere Fassung
  dieses Plans verlangte `ae/oe/ue` in Kommentaren; das war ein Irrtum des
  Verfassers und widerspricht jeder Nachbardatei.
- Sprachdateien immer **beide** (`lang/de/`, `lang/en/`).
- Tests laufen mit `docker compose exec -u 1000:1000 -T app php artisan test`.

---

## Dateiaufstellung

| Datei | Verantwortung |
|---|---|
| `app/Services/Mail/GuestMailConfig.php` | **neu** — welche Mailwerte eine Instanz bekommt, oder warum keine |
| `app/Support/NextcloudOcc.php` | **ändern** — eine Befehlsform, bei der der Wert erst IM Container eingesetzt wird |
| `app/Provisioning/Steps/Customer/ConfigureInstanceMail.php` | **neu** — trägt die Werte in den Gast |
| `app/Console/Commands/ConfigureInstanceMail.php` | **neu** — Bestand nachrüsten |
| `config/provisioning.php` | **ändern** — Schritt in `customer`, neue Pipeline `instance-mail` |
| `database/migrations/…_add_nextcloud_columns_to_seats.php` | **neu** — `nc_username`, `nc_state`, `nc_error`, `nc_synced_at` |
| `app/Models/Seat.php` | **ändern** — Zustände, Rollen→Gruppen |
| `app/Services/Nextcloud/NextcloudUsers.php` | **neu** — spricht occ: anlegen, einladen, Gruppen, Speicherplatz, sperren |
| `app/Provisioning/Jobs/SyncSeatToNextcloud.php` | **neu** — der Auftrag, der einen Sitz spiegelt |
| `app/Livewire/Users.php` | **ändern** — anlegen ≠ einladen, Ratelimit, entziehen ohne Löschen |
| `resources/views/livewire/users.blade.php` | **ändern** — Zustände, Fehler, Wiederholen |
| `lang/{de,en}/users.php` | **ändern** — neue Texte |

---

## Task 1: Die Mailwerte einer Instanz — und wann es keine gibt

**Files:**
- Create: `app/Services/Mail/GuestMailConfig.php`
- Create: `tests/Feature/Mail/GuestMailConfigTest.php`

**Interfaces:**
- Consumes: `App\Support\Settings`, `App\Models\Mailbox`, `App\Models\Instance`
- Produces: `GuestMailConfig::for(Instance $instance): self`, `->available(): bool`, `->problem(): ?string`, `->values(): array<string,string>`, `->password(): string`

Diese Aufgabe fasst keinen Gast an. Sie beantwortet nur die Frage „welche Werte, oder warum keine" — damit sie ohne Proxmox prüfbar ist.

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

`tests/Feature/Mail/GuestMailConfigTest.php`:

```php
<?php // tests/Feature/Mail/GuestMailConfigTest.php

use App\Models\Instance;
use App\Models\Mailbox;
use App\Services\Mail\GuestMailConfig;
use App\Support\Settings;

function versandbereit(): void
{
    Settings::set('mail.host', 'mail.clupilot.cloud');
    Settings::set('mail.port', 587);
    Settings::set('mail.encryption', 'tls');

    Mailbox::factory()->create([
        'key' => 'instance-relay',
        'address' => 'noreply@clupilot.cloud',
        'username' => 'noreply@clupilot.cloud',
        'password' => 'geheim',
        'active' => true,
        'authenticates' => true,
    ]);
}

it('baut die Werte aus Server UND Postfach zusammen', function () {
    versandbereit();
    $instance = Instance::factory()->create(['name' => 'Musterfirma']);

    $config = GuestMailConfig::for($instance);

    expect($config->available())->toBeTrue()
        ->and($config->values())->toMatchArray([
            'mail_smtpmode' => 'smtp',
            'mail_smtphost' => 'mail.clupilot.cloud',
            'mail_smtpport' => '587',
            'mail_smtpsecure' => 'tls',
            'mail_smtpauth' => 'true',
            'mail_smtpname' => 'noreply@clupilot.cloud',
            'mail_from_address' => 'noreply',
            'mail_domain' => 'clupilot.cloud',
        ])
        ->and($config->password())->toBe('geheim');
});

it('traegt das Passwort NICHT unter values()', function () {
    // values() wandert in Befehle. Das Passwort geht einen eigenen Weg, damit
    // niemand es versehentlich mit den uebrigen Werten mitschleift.
    versandbereit();

    expect(GuestMailConfig::for(Instance::factory()->create())->values())
        ->not->toHaveKey('mail_smtppassword');
});

it('sagt ohne Mailserver, dass nichts geschrieben werden darf', function () {
    Mailbox::factory()->create(['key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', 'password' => 'geheim', 'active' => true]);
    Settings::set('mail.host', '');

    $config = GuestMailConfig::for(Instance::factory()->create());

    expect($config->available())->toBeFalse()
        ->and($config->problem())->toBe('no_server');
});

it('sagt ohne Absenderpostfach, dass nichts geschrieben werden darf', function () {
    Settings::set('mail.host', 'mail.clupilot.cloud');
    Settings::set('mail.port', 587);

    $config = GuestMailConfig::for(Instance::factory()->create());

    expect($config->available())->toBeFalse()
        ->and($config->problem())->toBe('no_mailbox');
});

it('weist ein Postfach ohne Zugangsdaten ab', function () {
    Settings::set('mail.host', 'mail.clupilot.cloud');
    Settings::set('mail.port', 587);
    Mailbox::factory()->create(['key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', 'password' => null, 'active' => true, 'authenticates' => true]);

    expect(GuestMailConfig::for(Instance::factory()->create())->problem())->toBe('no_mailbox');
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Mail/GuestMailConfigTest.php`
Erwartet: FEHLSCHLAG, `Class "App\Services\Mail\GuestMailConfig" not found`

- [ ] **Schritt 3: Die Klasse schreiben**

`app/Services/Mail/GuestMailConfig.php`:

```php
<?php

namespace App\Services\Mail;

use App\Models\Instance;
use App\Models\Mailbox;
use App\Support\Settings;

/**
 * Welche Mailwerte die Nextcloud eines Kunden bekommt — und wann keine.
 *
 * Server und Zugangsdaten liegen in diesem Projekt an ZWEI Orten, und das ist
 * Absicht: der Server steht einmal in `app_settings` (ein Anbieterwechsel ist
 * dann eine Karte, nicht fuenf Zeilen), die Zugangsdaten je Absender am
 * Mailbox-Datensatz. Diese Klasse ist die einzige Stelle, die beides
 * zusammenlegt.
 *
 * `available() === false` heisst: NICHTS schreiben. Eine halb eingetragene
 * Mailkonfiguration ist schlimmer als keine — Nextcloud versucht dann bei
 * jedem Versand zu senden und scheitert still, ohne dass jemand es merkt.
 *
 * DIE NAHTSTELLE fuer spaeter: `mailboxFor()` gibt heute das gemeinsame
 * Versandkonto zurueck. Ein Konto je Kunde (ueber die mailcow-Schnittstelle
 * angelegt) haengt genau hier — der Bereitstellungsschritt und alles darueber
 * aendern sich dafuer nicht um eine Zeile.
 */
final class GuestMailConfig
{
    /** Der Schluessel des gemeinsamen Versandkontos in `mailboxes`. */
    public const RELAY_KEY = 'instance-relay';

    private function __construct(
        private readonly ?string $problem,
        /** @var array<string, string> */
        private readonly array $values,
        private readonly string $password,
    ) {}

    public static function for(Instance $instance): self
    {
        $host = trim((string) Settings::get('mail.host', ''));
        $port = (int) Settings::get('mail.port', 0);

        if ($host === '' || $port < 1) {
            return new self('no_server', [], '');
        }

        $box = self::mailboxFor($instance);

        if ($box === null || ! $box->isConfigured()) {
            return new self('no_mailbox', [], '');
        }

        // Die Adresse zerfaellt in die beiden Werte, die Nextcloud getrennt
        // fuehrt: den linken Teil als Absender, den rechten als Maildomain.
        [$local, $domain] = array_pad(explode('@', $box->address, 2), 2, '');

        return new self(null, [
            'mail_smtpmode' => 'smtp',
            'mail_smtphost' => $host,
            'mail_smtpport' => (string) $port,
            'mail_smtpsecure' => (string) Settings::get('mail.encryption', 'tls'),
            'mail_smtpauth' => 'true',
            'mail_smtpname' => $box->smtpUsername(),
            'mail_from_address' => $local,
            'mail_domain' => $domain,
        ], (string) $box->password);
    }

    /**
     * Heute fuer jede Instanz dasselbe Konto. Siehe Klassenkopf — hier haengt
     * die spaetere Fassung mit einem Konto je Kunde.
     */
    private static function mailboxFor(Instance $instance): ?Mailbox
    {
        return Mailbox::findByKey(self::RELAY_KEY);
    }

    public function available(): bool
    {
        return $this->problem === null;
    }

    /** `no_server`, `no_mailbox` oder null. */
    public function problem(): ?string
    {
        return $this->problem;
    }

    /**
     * Alles ausser dem Passwort. Das geht einen eigenen Weg, damit es beim
     * Bauen der Befehle nicht versehentlich mitwandert.
     *
     * @return array<string, string>
     */
    public function values(): array
    {
        return $this->values;
    }

    public function password(): string
    {
        return $this->password;
    }
}
```

- [ ] **Schritt 4: Laufen lassen, grün**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Mail/GuestMailConfigTest.php`
Erwartet: 5 grün

- [ ] **Schritt 5: Festschreiben**

```bash
git commit -F- -- app/Services/Mail/GuestMailConfig.php tests/Feature/Mail/GuestMailConfigTest.php <<'MSG'
Welche Mailwerte eine Kundeninstanz bekommt — und wann gar keine

Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle
zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb
eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still
scheitern.

mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt.
MSG
```

---

## Task 2: ENTFAELLT — der Mechanismus schuetzte vor nichts

**Diese Aufgabe wurde waehrend der Umsetzung gestrichen.** Sie sollte eine
occ-Befehlsform bauen, bei der das SMTP-Passwort erst von der Shell IM
Container eingesetzt wird, damit es nicht in der Prozessliste der Kunden-VM
steht. Die Pruefung hat nachgerechnet, dass das nichts bewirkt:

1. `guestExec()` uebergibt die GANZE Zeile als `['/bin/sh','-c',…]` an den
   Gastagenten — das Zuweisungspraefix mit dem Geheimnis steht also im Argv
   eines VM-Prozesses. Debians `dash` behaelt diesen Prozess fuer die volle
   Dauer; `bash` wuerde ihn wegoptimieren, `dash` tut es nicht.
2. Die innere Shell setzt `"$VAR"` VOR dem `exec` ein — der Klartext steht
   danach im Argv von `php occ` im Container.
3. Nextcloud legt `mail_smtppassword` anschliessend im Klartext in
   `config/config.php` ab. Wer im Container `/proc` liest, liest auch diese
   Datei; wer auf der VM Zugriff hat, ebenso.

Eingespart wurde einzig das Argv des `docker`-Aufrufs — auf einer Maschine,
auf der derselbe Wert zwei Zeilen darueber und dauerhaft in einer Datei steht.
Ein Versteck, das dieselbe Person nicht aussperrt, die den Ablageort ohnehin
lesen kann, ist keines.

**Was stattdessen gilt:** Aufgabe 3 schreibt das Passwort mit dem gewoehnlichen
`NextcloudOcc::command()` als Argument und sagt in ihrem Kopfkommentar
ausdruecklich, wo es ueberall sichtbar ist. Die Eingrenzung liegt in der
Infrastruktur — Versandkonto nur mit Senderecht, Versandport nur fuer die
eigenen Hostadressen, Sendelimit je Konto — und die traegt der Betreiber,
nicht der Code.

- [ ] **Schritt 1: `commandExpandingEnv()` und ihre Pruefungen zuruecknehmen**

`app/Support/NextcloudOcc.php` und `tests/Feature/NextcloudOccTest.php` auf den
Stand vor dieser Aufgabe zuruecksetzen. `command()` und `exec()` bleiben
unveraendert, wie sie immer waren.

- [ ] **Schritt 2: Festschreiben**

```bash
git commit -F- -- app/Support/NextcloudOcc.php tests/Feature/NextcloudOccTest.php <<'MSG'
commandExpandingEnv zurueckgenommen — sie schuetzte vor nichts

Die Methode sollte das SMTP-Passwort aus der Prozessliste der Kunden-VM
halten. Nachgerechnet: guestExec faehrt die ganze Zeile als /bin/sh -c, die
innere Shell setzt den Wert vor dem exec ein, und Nextcloud legt ihn danach
ohnehin im Klartext in config.php ab. Eingespart wurde einzig das Argv des
docker-Aufrufs, auf einer Maschine, die den Wert an zwei anderen Stellen
zeigt.

Ein Versteck, das dieselbe Person nicht aussperrt, die den Ablageort lesen
kann, ist keines — und ein Kommentar, der Schutz behauptet, haelt den
Naechsten vom Nachsehen ab.
MSG
```

---

## Task 3: Der Bereitstellungsschritt, der den Versand einträgt

**Files:**
- Create: `app/Provisioning/Steps/Customer/ConfigureInstanceMail.php`
- Modify: `config/provisioning.php`
- Create: `tests/Feature/Provisioning/ConfigureInstanceMailTest.php`

**Interfaces:**
- Consumes: `GuestMailConfig::for()`, `NextcloudOcc::command()`, `CustomerStep::guest()`
- Produces: Schrittschlüssel `configure_instance_mail`; Pipeline `instance-mail`

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Provisioning/ConfigureInstanceMailTest.php

use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\ProvisioningRun;
use App\Provisioning\Steps\Customer\ConfigureInstanceMail;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\Settings;

function laufMitInstanz(Instance $instance): ProvisioningRun
{
    return ProvisioningRun::factory()->create([
        'pipeline' => 'instance-mail',
        'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 201],
    ]);
}

function versandbereitFuerSchritt(): void
{
    Settings::set('mail.host', 'mail.clupilot.cloud');
    Settings::set('mail.port', 587);
    Settings::set('mail.encryption', 'tls');
    Mailbox::factory()->create([
        'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud',
        'username' => 'noreply@clupilot.cloud', 'password' => 'geheim',
        'active' => true, 'authenticates' => true,
    ]);
}

it('traegt jeden Wert einzeln in den Gast', function () {
    versandbereitFuerSchritt();
    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);
    $instance = Instance::factory()->create();

    app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));

    $befehle = implode("\n", $pve->guestCommands);

    expect($befehle)->toContain('config:system:set mail_smtphost --value=')
        ->and($befehle)->toContain('mail.clupilot.cloud')
        ->and($befehle)->toContain('config:system:set mail_domain --value=')
        ->and($befehle)->toContain('clupilot.cloud');
});

it('maskiert das Passwort, sodass es keinen zweiten Befehl starten kann', function () {
    // Verstecken laesst sich der Wert auf dieser Maschine nicht (siehe
    // Kopfkommentar des Schrittes). Was sehr wohl gilt und geprueft gehoert:
    // er darf aus seiner Klammerung nicht ausbrechen. Der Befehl laeuft als
    // root auf einer Kundenmaschine — ein Semikolon im Passwort waere dort
    // ein zweiter Befehl.
    Settings::set('mail.host', 'mail.clupilot.cloud');
    Settings::set('mail.port', 587);
    Mailbox::factory()->create([
        'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud',
        'username' => 'noreply@clupilot.cloud',
        'password' => "boes'; touch /tmp/PWNED; echo '",
        'active' => true, 'authenticates' => true,
    ]);

    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);

    app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create()));

    $passwortbefehl = collect($pve->guestCommands)
        ->first(fn ($b) => str_contains($b, 'mail_smtppassword'));

    // Der ganze Wert steht in EINEM maskierten Argument: das Semikolon darf
    // nicht ausserhalb der Anfuehrungszeichen stehen.
    expect($passwortbefehl)->toContain(escapeshellarg("boes'; touch /tmp/PWNED; echo '"));
});

it('schreibt GAR NICHTS, wenn der Mailserver fehlt', function () {
    Settings::set('mail.host', '');
    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);

    $ergebnis = app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create()));

    expect($pve->guestCommands)->toBe([])
        ->and($ergebnis->type)->toBe(\App\Provisioning\StepResult::FAIL)
        ->and($ergebnis->reason)->toBe('no_server');
});

it('schreibt beim zweiten Lauf erneut, statt sich mit einem Merker zu sperren', function () {
    versandbereitFuerSchritt();
    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);
    $instance = Instance::factory()->create();

    app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));
    $ersteRunde = count($pve->guestCommands);
    app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance));

    // config:system:set ist von sich aus wiederholbar — derselbe Wert zweimal
    // geschrieben ist derselbe Wert. Der Schritt darf deshalb ohne Merker
    // erneut laufen; das ist bei einer Nachruestung ueber den Bestand der
    // Normalfall, nicht die Ausnahme.
    expect(count($pve->guestCommands))->toBe($ersteRunde * 2);
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Provisioning/ConfigureInstanceMailTest.php`
Erwartet: FEHLSCHLAG, Klasse nicht gefunden

- [ ] **Schritt 3: Den Schritt schreiben**

`app/Provisioning/Steps/Customer/ConfigureInstanceMail.php`:

```php
<?php

namespace App\Provisioning\Steps\Customer;

use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Mail\GuestMailConfig;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\NextcloudOcc;

/**
 * Bringt der Nextcloud eines Kunden bei, Mail zu verschicken.
 *
 * Bis hierher konnte sie es NICHT — weder die Bereitstellung noch die
 * Compose-Datei der Vorlage setzten je einen Versand. Damit verschickte jede
 * Kundeninstanz gar nichts: keine Freigabe-Benachrichtigung, keine
 * Aktivitaetsmeldung, kein "Passwort vergessen". Still, ohne Fehlermeldung.
 *
 * Das ist die Voraussetzung fuer die Mitarbeiterverwaltung: die Einladung an
 * einen Mitarbeiter verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht
 * das Passwort dort, wo niemand sonst es zu sehen bekommt.
 *
 * Ohne Server oder Absenderpostfach wird NICHTS geschrieben und der Schritt
 * scheitert mit einem Grund. Eine halb eingetragene Mailkonfiguration waere
 * schlimmer als keine.
 *
 * WO DAS SMTP-PASSWORT UEBERALL SICHTBAR IST — ehrlich aufgeschrieben, weil
 * ein erster Entwurf es zu verstecken versuchte und das Verstecken nichts
 * brachte:
 *
 *  - im Argv des `/bin/sh -c`, das der Gastagent fuer diesen Aufruf startet
 *    (guestExec baut die ganze Zeile so). Debians `dash` behaelt diesen
 *    Prozess fuer die volle Dauer des Aufrufs;
 *  - im Argv von `php occ` im Container;
 *  - und DAUERHAFT im Klartext in /opt/nextcloud … config/config.php, wo
 *    Nextcloud es selbst ablegt.
 *
 * Der dritte Punkt erledigt die ersten beiden: wer die Datei lesen kann, muss
 * gar nicht erst in eine Prozessliste schauen. Ein Versteck, das dieselbe
 * Person nicht aussperrt, ist keines — und ein Kommentar, der eines
 * behauptet, haelt den Naechsten vom Nachsehen ab.
 *
 * Die Eingrenzung liegt deshalb in der Infrastruktur, nicht hier: das
 * Versandkonto kann nur senden, der Versandport nimmt nur die eigenen
 * Hostadressen an, und der Mailserver drosselt je Konto. Was hier gilt und
 * geprueft wird, ist etwas anderes: der Wert wird maskiert, damit er keinen
 * zweiten Befehl starten kann.
 */
class ConfigureInstanceMail extends CustomerStep
{
    public function __construct(private ProxmoxClient $pve) {}

    public function key(): string
    {
        return 'configure_instance_mail';
    }

    public function maxDuration(): int
    {
        return 120;
    }

    public function execute(ProvisioningRun $run): StepResult
    {
        $instance = $this->instance($run);

        if ($instance === null) {
            return StepResult::fail('instance_missing');
        }

        $config = GuestMailConfig::for($instance);

        if (! $config->available()) {
            return StepResult::fail($config->problem() ?? 'mail_unavailable');
        }

        $pve = $this->pve->forHost($instance->host);

        foreach ($config->values() as $schluessel => $wert) {
            $this->guest($pve, $run, NextcloudOcc::command(
                'config:system:set '.escapeshellarg($schluessel).' --value='.escapeshellarg($wert)
            ));
        }

        // Das Passwort wie jeder andere Wert. Verstecken laesst es sich auf
        // dieser Maschine nicht — siehe Kopfkommentar.
        $this->guest($pve, $run, NextcloudOcc::command(
            'config:system:set mail_smtppassword --value='.escapeshellarg($config->password())
        ));

        return StepResult::advance();
    }
}
```

- [ ] **Schritt 4: In die Pipelines eintragen**

In `config/provisioning.php`, in der `customer`-Pipeline **direkt nach** `Customer\ConfigureNextcloud::class` einfügen:

```php
            // Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt
            // keine Mail — auch nicht die Einladung an einen Mitarbeiter, die
            // Nextcloud selbst verschickt.
            Customer\ConfigureInstanceMail::class,
```

Und als eigene Pipeline für die Nachrüstung, neben `host-firewall`:

```php
        /*
        | Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt,
        | derselbe wie im Aufbau — ein zweiter Weg, dieselben Werte zu
        | schreiben, wuerde driften.
        */
        'instance-mail' => [
            Customer\ConfigureInstanceMail::class,
        ],
```

- [ ] **Schritt 5: Laufen lassen, grün**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Provisioning/ConfigureInstanceMailTest.php`
Erwartet: 4 grün

- [ ] **Schritt 6: Die Pipeline-Prüfungen laufen lassen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test --filter=Pipeline`
Erwartet: grün — bestehende Prüfungen über die Schrittfolge müssen den neuen Schritt vertragen. Schlagen sie fehl, weil sie eine feste Schrittzahl erwarten, wird die Zahl dort mitgezogen.

- [ ] **Schritt 7: Festschreiben**

```bash
git commit -F- -- app/Provisioning/Steps/Customer/ConfigureInstanceMail.php config/provisioning.php tests/Feature/Provisioning/ConfigureInstanceMailTest.php <<'MSG'
Die Kundeninstanz lernt Mail verschicken

Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.

Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.
MSG
```

---

## Task 4: Bestehende Instanzen nachrüsten

**Files:**
- Create: `app/Console/Commands/ConfigureInstanceMail.php`
- Create: `tests/Feature/Console/ConfigureInstanceMailCommandTest.php`

**Interfaces:**
- Consumes: Pipeline `instance-mail` aus Aufgabe 3
- Produces: `clupilot:configure-instance-mail` mit `--dry-run` und `--instance=`

Vorbild ist `app/Console/Commands/RefreshHostFirewall.php` — gleiche Form, gleiche Begründung: ein Loch, das sich endgültig schließt, wird von Hand geschlossen und nicht von einem Zeitplan überdeckt.

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Console/ConfigureInstanceMailCommandTest.php

use App\Models\Instance;
use App\Models\ProvisioningRun;

it('startet je aktiver Instanz einen Lauf', function () {
    Instance::factory()->count(2)->create(['status' => 'active']);
    Instance::factory()->create(['status' => 'closed']);

    $this->artisan('clupilot:configure-instance-mail')->assertSuccessful();

    expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(2);

    // Und der Auftrag bekommt die uuid, nicht die Id. Ohne DIESE Zusicherung
    // bleibt eine vertauschte Kennung unsichtbar: der Befehl meldet
    // "gestartet", die Warteschlange findet nie eine Zeile, und der Lauf
    // steht fuer immer auf running. Zeilen zu zaehlen genuegt nicht — es
    // zaehlt, WAS uebergeben wird.
    Queue::assertPushed(AdvanceRunJob::class, fn ($job) => ProvisioningRun::where('uuid', $job->runUuid)->exists());
});

it('faengt unter --dry-run gar nichts an', function () {
    Instance::factory()->count(3)->create(['status' => 'active']);

    $this->artisan('clupilot:configure-instance-mail', ['--dry-run' => true])->assertSuccessful();

    expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(0);
});

it('nimmt mit --instance genau eine', function () {
    $eine = Instance::factory()->create(['status' => 'active']);
    Instance::factory()->create(['status' => 'active']);

    $this->artisan('clupilot:configure-instance-mail', ['--instance' => $eine->uuid])->assertSuccessful();

    expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(1);
});

it('ueberspringt eine Instanz ohne Host und sagt es', function () {
    Instance::factory()->create(['status' => 'active', 'host_id' => null]);

    $this->artisan('clupilot:configure-instance-mail')
        ->expectsOutputToContain('kein Host')
        ->assertSuccessful();

    expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(0);
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Console/ConfigureInstanceMailCommandTest.php`
Erwartet: FEHLSCHLAG, Befehl unbekannt

- [ ] **Schritt 3: Den Befehl schreiben**

`app/Console/Commands/ConfigureInstanceMail.php`:

```php
<?php

namespace App\Console\Commands;

use App\Models\Instance;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Console\Command;

/**
 * Traegt bestehenden Instanzen den Mailversand nach.
 *
 * Der Anlass: der Versand kam erst mit der Mitarbeiterverwaltung in die
 * Pipeline. Jede vorher gebaute Instanz kann bis heute keine Mail verschicken
 * — und die Einladung an einen Mitarbeiter geht von DORT aus, nicht von
 * CluPilot. Ohne diesen Lauf bliebe die Verwaltung fuer alle Altkunden tot.
 *
 * Ein Befehl und kein Zeitplan, aus derselben Begruendung wie bei
 * `clupilot:refresh-host-firewall`: das Loch ist endlich und schliesst sich
 * endgueltig; ein naechtlicher Lauf wuerde am Tag, an dem der Schritt in der
 * Pipeline still kaputtginge, fuer ihn einspringen und das Versagen
 * verbergen; und eine Reparatur, die der Betreiber anstoesst, ist eine, deren
 * Ausgabe er liest.
 */
class ConfigureInstanceMail extends Command
{
    protected $signature = 'clupilot:configure-instance-mail
        {--dry-run : auflisten, was liefe, und nichts anfassen}
        {--instance= : nur diese eine Instanz, per uuid}';

    protected $description = 'Traegt bestehenden Kundeninstanzen den Mailversand nach';

    public function handle(): int
    {
        $dryRun = (bool) $this->option('dry-run');
        $gestartet = 0;

        /** @var array<string, int> Grund => Anzahl */
        $uebersprungen = [];

        $query = Instance::query()->where('status', 'active');

        if ($uuid = $this->option('instance')) {
            $query->where('uuid', $uuid);
        }

        foreach ($query->with('host')->get() as $instance) {
            if ($instance->host === null) {
                $this->warn("{$instance->uuid}: kein Host — uebersprungen");
                $uebersprungen['kein Host'] = ($uebersprungen['kein Host'] ?? 0) + 1;

                continue;
            }

            if (blank($instance->vmid)) {
                $this->warn("{$instance->uuid}: keine VMID — uebersprungen");
                $uebersprungen['keine VMID'] = ($uebersprungen['keine VMID'] ?? 0) + 1;

                continue;
            }

            if ($dryRun) {
                $this->line("{$instance->uuid}: wuerde nachgetragen");
                $gestartet++;

                continue;
            }

            $run = ProvisioningRun::create([
                'pipeline' => 'instance-mail',
                'subject_type' => Instance::class,
                'subject_id' => $instance->id,
                'status' => ProvisioningRun::STATUS_PENDING,
                'current_step' => 0,
                'context' => [
                    'instance_id' => $instance->id,
                    'node' => $instance->host->node ?? 'pve',
                    'vmid' => (int) $instance->vmid,
                ],
            ]);

            // uuid, NICHT id: AdvanceRunJob sucht in handle() per
            // where('uuid', ...). HasUuid erzeugt die uuid unabhaengig von
            // der Autoincrement-Id; beide sind nie gleich. Mit der Id findet
            // der Auftrag in Produktion nie eine Zeile, endet still ohne
            // Ausnahme, und der Lauf steht fuer immer auf running — waehrend
            // dieser Befehl "gestartet" meldet. Jeder andere Aufrufer im Repo
            // macht es so.
            AdvanceRunJob::dispatch($run->uuid);
            $this->info("{$instance->uuid}: Lauf {$run->id} gestartet");
            $gestartet++;
        }

        $this->newLine();
        $this->line($dryRun ? "{$gestartet} Instanz(en) waeren nachgetragen worden." : "{$gestartet} Lauf/Laeufe gestartet.");

        foreach ($uebersprungen as $grund => $anzahl) {
            $this->line("uebersprungen ({$grund}): {$anzahl}");
        }

        return self::SUCCESS;
    }
}
```

- [ ] **Schritt 4: Laufen lassen, grün**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Console/ConfigureInstanceMailCommandTest.php`
Erwartet: 4 grün

- [ ] **Schritt 5: Festschreiben**

```bash
git commit -F- -- app/Console/Commands/ConfigureInstanceMail.php tests/Feature/Console/ConfigureInstanceMailCommandTest.php <<'MSG'
Bestehende Instanzen bekommen den Mailversand nachgetragen

Ohne diesen Lauf bliebe die Mitarbeiterverwaltung fuer jeden Altkunden tot:
die Einladung geht von SEINER Nextcloud aus, und die kann bis heute nichts
verschicken. Ein Befehl, kein Zeitplan — dieselbe Begruendung wie bei
clupilot:refresh-host-firewall.
MSG
```

---

## Task 5: Das Datenmodell — Absicht und Wirklichkeit getrennt

**Files:**
- Create: `database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php`
- Modify: `app/Models/Seat.php`
- Create: `tests/Feature/Seats/SeatModelTest.php`

**Interfaces:**
- Produces: `Seat::STATE_NONE|STATE_PENDING|STATE_SYNCED|STATE_FAILED`, `Seat::GROUPS` (Rolle → Nextcloud-Gruppe), `Seat::isReadonly(): bool`

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Seats/SeatModelTest.php

use App\Models\Customer;
use App\Models\Instance;
use App\Models\Seat;

it('startet jeden Sitz als noch nicht in der Nextcloud', function () {
    expect(Seat::factory()->create()->nc_state)->toBe(Seat::STATE_NONE);
});

it('bildet jede Rolle auf genau eine Nextcloud-Gruppe ab', function () {
    // Keine Rolle ohne Gruppe: eine Rolle, die im Portal waehlbar ist und im
    // Gast nichts bewirkt, ist genau die Attrappe, die hier abgeschafft wird.
    foreach (Seat::ROLES as $rolle) {
        expect(Seat::GROUPS)->toHaveKey($rolle)
            ->and(Seat::GROUPS[$rolle])->not->toBe('');
    }
});

it('fuehrt owner und admin in die Admin-Gruppe', function () {
    expect(Seat::GROUPS['owner'])->toBe('admin')
        ->and(Seat::GROUPS['admin'])->toBe('admin');
});

it('erkennt die Rolle, die keinen Speicherplatz bekommt', function () {
    expect(Seat::factory()->create(['role' => 'readonly'])->isReadonly())->toBeTrue()
        ->and(Seat::factory()->create(['role' => 'member'])->isReadonly())->toBeFalse();
});

it('verknuepft den Inhaber-Sitz mit dem bestehenden Admin-Konto', function () {
    // Die Wanderung. Der owner-Sitz eines Kunden, dessen Instanz ein
    // nc_admin_ref traegt, IST dieses Konto — die Bereitstellung hat es
    // angelegt. Stuende er auf 'none', boete das Panel dem Inhaber an, sich
    // selbst einzuladen, und der Auftrag traefe auf einen Benutzer, den es
    // laengst gibt.
    $customer = Customer::factory()->create();
    Instance::factory()->for($customer)->create(['nc_admin_ref' => 'admin', 'status' => 'active']);
    $sitz = Seat::factory()->for($customer)->create(['role' => 'owner']);

    // Die Wanderung lief beim Anlegen der Testdatenbank; hier wird die
    // Nachziehmethode geprueft, die sie benutzt.
    $sitz->linkToInstanceAdmin();

    expect($sitz->fresh()->nc_username)->toBe('admin')
        ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SeatModelTest.php`
Erwartet: FEHLSCHLAG, `Undefined constant … STATE_NONE`

- [ ] **Schritt 3: Die Wanderung schreiben**

`database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php`:

```php
<?php

use App\Models\Instance;
use App\Models\Seat;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * Ein Sitz fuehrt ab hier ZWEI Angaben nebeneinander.
 *
 * `status` ist, was der Inhaber WILL — er aendert sich sofort beim Klick.
 * `nc_state` ist, was in der Nextcloud WIRKLICH ist — er aendert sich, wenn
 * der Auftrag durch ist. Ein einzelnes Feld muesste luegen: entweder es
 * behauptet einen Erfolg, den nur die Warteschlange bringen kann, oder es
 * verschweigt dem Inhaber, dass sein Klick angekommen ist.
 *
 * Nur so laesst sich "fehlgeschlagen" ueberhaupt sagen.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('seats', function (Blueprint $table) {
            // Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer
            // nicht umbenennen.
            $table->string('nc_username')->nullable()->after('email');
            $table->string('nc_state', 16)->default('none')->after('status');
            $table->text('nc_error')->nullable()->after('nc_state');
            $table->timestamp('nc_synced_at')->nullable()->after('nc_error');
        });

        // Bestandssitze sind ehrlich beschrieben: angelegt, nie eingeladen —
        // denn eingeladen hat sie nie jemand, resend() war eine Attrappe.
        // EINE Ausnahme: der Inhaber-Sitz IST das Admin-Konto der Instanz.
        Seat::query()->where('role', 'owner')->with('customer')->chunkById(100, function ($sitze) {
            foreach ($sitze as $sitz) {
                $sitz->linkToInstanceAdmin();
            }
        });
    }

    public function down(): void
    {
        Schema::table('seats', function (Blueprint $table) {
            $table->dropColumn(['nc_username', 'nc_state', 'nc_error', 'nc_synced_at']);
        });
    }
};
```

- [ ] **Schritt 4: Das Modell erweitern**

In `app/Models/Seat.php`:

```php
    public const ROLES = ['owner', 'admin', 'member', 'readonly'];

    /**
     * Welche Nextcloud-Gruppe eine Rolle bedeutet.
     *
     * Rollen sind Gruppen im Gast, damit weitere spaeter eine Zeile sind und
     * kein Umbau. `owner` und `admin` teilen sich `admin` — der Unterschied
     * zwischen beiden ist eine CluPilot-Angelegenheit (der letzte owner darf
     * nicht entfernt werden), keine Nextcloud-Angelegenheit.
     */
    public const GROUPS = [
        'owner' => 'admin',
        'admin' => 'admin',
        'member' => 'mitarbeiter',
        'readonly' => 'nur-lesen',
    ];

    public const STATE_NONE = 'none';
    public const STATE_PENDING = 'pending';
    public const STATE_SYNCED = 'synced';
    public const STATE_FAILED = 'failed';

    protected $fillable = [
        'customer_id', 'email', 'name', 'role', 'status', 'invited_at',
        'nc_username', 'nc_state', 'nc_error', 'nc_synced_at',
    ];

    protected function casts(): array
    {
        return ['invited_at' => 'datetime', 'nc_synced_at' => 'datetime'];
    }

    /**
     * Die Rolle, die keinen eigenen Speicherplatz bekommt — und die einzige,
     * bei der ein eigener Wert am Konto richtig ist. Siehe NextcloudUsers.
     */
    public function isReadonly(): bool
    {
        return $this->role === 'readonly';
    }

    /**
     * Verknuepft einen Inhaber-Sitz mit dem Admin-Konto seiner Instanz.
     *
     * Dieses Konto existiert in der Nextcloud tatsaechlich — CreateCustomerAdmin
     * hat es beim Aufbau angelegt. Ein zweites Admin-Konto fuer dieselbe Person
     * waere eines zu viel.
     */
    public function linkToInstanceAdmin(): void
    {
        $ref = $this->customer?->instances()
            ->whereIn('status', ['active', 'cancellation_scheduled'])
            ->latest('id')->first()?->nc_admin_ref;

        if (blank($ref)) {
            return;
        }

        $this->forceFill([
            'nc_username' => $ref,
            'nc_state' => self::STATE_SYNCED,
            'nc_synced_at' => now(),
        ])->save();
    }
```

- [ ] **Schritt 5: Wandern und laufen lassen**

Ausführen:
```bash
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SeatModelTest.php
```
Erwartet: 5 grün

- [ ] **Schritt 6: Festschreiben**

```bash
git commit -F- -- database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php app/Models/Seat.php tests/Feature/Seats/SeatModelTest.php <<'MSG'
Ein Sitz fuehrt Absicht und Wirklichkeit getrennt

status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein
einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht
sagen.

Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto
verknuepft, das die Bereitstellung laengst angelegt hat.
MSG
```

---

## Task 6: Der Dienst, der occ spricht

**Files:**
- Create: `app/Services/Nextcloud/NextcloudUsers.php`
- Create: `tests/Feature/Nextcloud/NextcloudUsersTest.php`

**Interfaces:**
- Consumes: `NextcloudOcc::command()`, `ProxmoxClient::guestExec()`, `Seat::GROUPS`
- Produces: `exists(Instance,string): bool`, `create(Instance,Seat): bool`, `invite(Instance,Seat): bool`, `applyRole(Instance,Seat): bool`, `disable(Instance,Seat): bool`, `enable(Instance,Seat): bool` — alle geben `false` zurück statt zu werfen

Diese Klasse ist der Griff, nicht die Entscheidung. Wer wann welchen Griff zieht, steht im Auftrag der nächsten Aufgabe.

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Nextcloud/NextcloudUsersTest.php

use App\Models\Instance;
use App\Models\Seat;
use App\Services\Nextcloud\NextcloudUsers;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;

function gastBereit(): array
{
    $pve = new FakeProxmoxClient;
    app()->instance(ProxmoxClient::class, $pve);

    return [$pve, Instance::factory()->create(['status' => 'active', 'vmid' => 201])];
}

it('legt einen Benutzer mit erzeugtem Passwort an, das niemand sieht', function () {
    [$pve, $instance] = gastBereit();
    $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'name' => 'Anna', 'nc_username' => 'anna@firma.tld']);

    app(NextcloudUsers::class)->invite($instance, $sitz);

    $befehle = implode("\n", $pve->guestCommands);

    // --generate-password: Nextcloud erzeugt es, NIEMAND bekommt es zu sehen.
    // --email: dorthin geht der Link, an dem der Mitarbeiter sein eigenes setzt.
    expect($befehle)->toContain('--generate-password')
        ->and($befehle)->toContain('--email')
        ->and($befehle)->toContain('anna@firma.tld');
});

it('verschickt bei einem bestehenden Benutzer nur die Willkommensmail neu', function () {
    [$pve, $instance] = gastBereit();
    $pve->guestScripts['user:info'] = ['exitcode' => 0, 'out-data' => 'user_id: anna@firma.tld'];
    $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld']);

    app(NextcloudUsers::class)->invite($instance, $sitz);

    $befehle = implode("\n", $pve->guestCommands);

    // Wiederholbar: ein zweiter Lauf nach einem Absturz legt keinen zweiten
    // Benutzer an. Genau wie CreateCustomerAdmin es tut.
    expect($befehle)->toContain('user:welcome --reset-password')
        ->and($befehle)->not->toContain('user:add');
});

it('setzt bei readonly einen eigenen Speicherplatz von null', function () {
    [$pve, $instance] = gastBereit();
    $sitz = Seat::factory()->create(['role' => 'readonly', 'nc_username' => 'anna@firma.tld']);

    app(NextcloudUsers::class)->applyRole($instance, $sitz);

    $befehle = implode("\n", $pve->guestCommands);

    expect($befehle)->toContain('group:adduser')
        ->and($befehle)->toContain('nur-lesen')
        ->and($befehle)->toContain('files quota')
        ->and($befehle)->toContain('0 B');
});

it('LOESCHT den eigenen Speicherplatz, wenn readonly verlassen wird', function () {
    // Die Falle aus ApplyStorageQuota: "An account with an explicit quota stops
    // following the default". Ein Konto, das mit einem festen Wert aus der
    // Rolle herauskommt, waere bei der naechsten Paketaenderung stumm
    // ausgenommen — und niemand merkte es, bis der Kunde fragt, warum sein
    // Mitarbeiter weniger Platz hat als bezahlt.
    [$pve, $instance] = gastBereit();
    $sitz = Seat::factory()->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']);

    app(NextcloudUsers::class)->applyRole($instance, $sitz);

    $befehle = implode("\n", $pve->guestCommands);

    expect($befehle)->toContain('files quota --delete')
        ->and($befehle)->not->toContain('0 B');
});

it('wirft Sitzungen beim Sperren SOFORT hinaus', function () {
    // user:disable allein laesst laufende Sitzungen bis zu fuenf Minuten
    // weiterleben. Bei einem Mitarbeiter, der gerade gegangen ist, sind fuenf
    // Minuten fuenf zu viel.
    [$pve, $instance] = gastBereit();
    $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']);

    app(NextcloudUsers::class)->disable($instance, $sitz);

    $befehle = implode("\n", $pve->guestCommands);

    expect($befehle)->toContain('user:disable')
        ->and($befehle)->toContain('user:auth-tokens:delete');
});

it('gibt false zurueck statt zu werfen, wenn der Gast nicht antwortet', function () {
    [$pve, $instance] = gastBereit();
    $pve->guestThrows[201] = new RuntimeException('guest agent unreachable');
    $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']);

    expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse();
});

it('fuehrt gar nichts aus, wenn der Benutzername keiner ist', function () {
    // Der Name wandert in eine Wurzel-Shell im Gast. Dieselbe Regel wie bei
    // HostFirewall: ein Dienst, der eine Shell fuettert, darf sich nicht
    // darauf verlassen, dass sein Aufrufer sauber war.
    [$pve, $instance] = gastBereit();
    $sitz = Seat::factory()->create(['nc_username' => 'anna; rm -rf /']);

    expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse()
        ->and($pve->guestCommands)->toBe([]);
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Nextcloud/NextcloudUsersTest.php`
Erwartet: FEHLSCHLAG, Klasse nicht gefunden

- [ ] **Schritt 3: Den Dienst schreiben**

`app/Services/Nextcloud/NextcloudUsers.php`:

```php
<?php

namespace App\Services\Nextcloud;

use App\Models\Instance;
use App\Models\Seat;
use App\Services\Proxmox\ProxmoxClient;
use App\Support\NextcloudOcc;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;

/**
 * Der Griff, mit dem ein Sitz in der Nextcloud eines Kunden wirksam wird.
 *
 * Nur Griffe: anlegen, einladen, Gruppe setzen, sperren, freigeben. WER wann
 * welchen zieht, steht in SyncSeatToNextcloud — dieselbe Trennung wie bei
 * HostFirewall und BlockAddress.
 *
 * Keine Methode wirft. Ein nicht erreichbarer Gast gibt `false` zurueck, und
 * der Auftrag schreibt das an den Sitz, wo der Inhaber es liest. Eine
 * Ausnahme wuerde stattdessen den Bereitstellungs-Arbeiter mitreissen, auf dem
 * die bezahlte Kundenbereitstellung laeuft.
 *
 * Jeder Benutzername geht vor dem Einsetzen durch `isWellFormed()`. Das ist
 * kein doppelter Boden fuer einen ohnehin sauberen Aufrufer, sondern die
 * Bedingung dafuer, dass dieser Dienst eine Shell im Gast fuettern darf.
 */
class NextcloudUsers
{
    public function __construct(private ProxmoxClient $pve) {}

    /** Anlegen falls noetig, danach die Willkommensmail — in einem Zug. */
    public function invite(Instance $instance, Seat $seat): bool
    {
        $user = (string) $seat->nc_username;

        if (! $this->isWellFormed($user, $seat)) {
            return false;
        }

        return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat) {
            $vorhanden = (int) ($pve->guestExec(
                $node, $vmid, NextcloudOcc::command('user:info '.escapeshellarg($user))
            )['exitcode'] ?? 1) === 0;

            // Wiederholbar nach einem Absturz: ein zweiter Lauf legt keinen
            // zweiten Benutzer an, sondern schickt die Willkommensmail erneut.
            // Genau wie CreateCustomerAdmin es tut.
            return $vorhanden
                ? ['user:welcome --reset-password '.escapeshellarg($user)]
                : [
                    'user:add --generate-password'
                        .' --email='.escapeshellarg((string) $seat->email)
                        .' --display-name='.escapeshellarg((string) ($seat->name ?: $seat->email))
                        .' --group='.escapeshellarg(Seat::GROUPS[$seat->role] ?? 'mitarbeiter')
                        .' '.escapeshellarg($user),
                ];
        });
    }

    /** Gruppe setzen — und bei readonly der Speicherplatz. */
    public function applyRole(Instance $instance, Seat $seat): bool
    {
        $user = (string) $seat->nc_username;

        if (! $this->isWellFormed($user, $seat)) {
            return false;
        }

        $ziel = Seat::GROUPS[$seat->role] ?? 'mitarbeiter';

        return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat, $ziel) {
            $befehle = [];

            // Aus jeder anderen bekannten Gruppe heraus, in die eine hinein.
            foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) {
                if ($gruppe !== $ziel) {
                    $befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' '.escapeshellarg($user);
                }
            }

            $befehle[] = 'group:adduser '.escapeshellarg($ziel).' '.escapeshellarg($user);

            // Der Speicherplatz. Siehe ApplyStorageQuota: ein Konto mit
            // EIGENEM Wert folgt der Vorgabe der Instanz nicht mehr. Fuer
            // readonly ist genau das gewollt; beim VERLASSEN der Rolle muss
            // der eigene Wert deshalb WEG, nicht ueberschrieben werden.
            $befehle[] = $seat->isReadonly()
                ? 'user:setting '.escapeshellarg($user).' files quota '.escapeshellarg('0 B')
                : 'user:setting '.escapeshellarg($user).' files quota --delete';

            return $befehle;
        });
    }

    public function disable(Instance $instance, Seat $seat): bool
    {
        $user = (string) $seat->nc_username;

        if (! $this->isWellFormed($user, $seat)) {
            return false;
        }

        return $this->run($instance, fn ($pve, $node, $vmid) => [
            'user:disable '.escapeshellarg($user),
            // user:disable allein laesst laufende Sitzungen bis zu fuenf
            // Minuten weiterleben. Bei jemandem, der gerade gegangen ist,
            // sind fuenf Minuten fuenf zu viel.
            'user:auth-tokens:delete '.escapeshellarg($user),
        ]);
    }

    public function enable(Instance $instance, Seat $seat): bool
    {
        $user = (string) $seat->nc_username;

        if (! $this->isWellFormed($user, $seat)) {
            return false;
        }

        return $this->run($instance, fn ($pve, $node, $vmid) => ['user:enable '.escapeshellarg($user)]);
    }

    /**
     * Nextcloud laesst Buchstaben, Ziffern und `-_.@` in Kennungen zu. Alles
     * andere ist entweder ein Fehler weiter oben oder ein Versuch — beides
     * will man sehen, und keines darf in eine Shell.
     */
    private function isWellFormed(string $user, Seat $seat): bool
    {
        if ($user !== '' && preg_match('/^[A-Za-z0-9._@-]+$/', $user) === 1) {
            return true;
        }

        report(new RuntimeException(
            "NextcloudUsers: abgewiesene Kennung fuer Sitz [{$seat->uuid}] — nichts ausgefuehrt."
        ));

        return false;
    }

    /**
     * Der Verbindungsaufbau steht EINMAL hier, nicht in jeder Methode. Der
     * Rueckruf bekommt den fertigen Client mit — er braucht ihn, weil `invite()`
     * erst nachsehen muss, ob es den Benutzer schon gibt, bevor es entscheidet,
     * welchen Befehl es baut.
     *
     * @param  callable(\App\Services\Proxmox\ProxmoxClient, string, int): array<int, string>  $bauen
     */
    private function run(Instance $instance, callable $bauen): bool
    {
        if ($instance->host === null || blank($instance->vmid)) {
            return false;
        }

        $node = $instance->host->node ?? 'pve';
        $vmid = (int) $instance->vmid;

        try {
            $pve = $this->pve->forHost($instance->host);
            $ok = true;

            foreach ($bauen($pve, $node, $vmid) as $argumente) {
                $ergebnis = $pve->guestExec($node, $vmid, NextcloudOcc::command($argumente));
                $ok = ((int) ($ergebnis['exitcode'] ?? 1) === 0) && $ok;
            }

            return $ok;
        } catch (Throwable $e) {
            // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern.
            // Nie mit Zugangsdaten, nie mit Stacktrace an den Kunden.
            Log::warning('nextcloud user command failed', [
                'instance' => $instance->uuid, 'error' => $e->getMessage(),
            ]);

            return false;
        }
    }
}
```

- [ ] **Schritt 4: Laufen lassen, grün**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Nextcloud/NextcloudUsersTest.php`
Erwartet: 7 grün

- [ ] **Schritt 5: Festschreiben**

```bash
git commit -F- -- app/Services/Nextcloud/NextcloudUsers.php tests/Feature/Nextcloud/NextcloudUsersTest.php <<'MSG'
Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird

Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht
erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem
die bezahlte Bereitstellung laeuft.

Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst
Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und
ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr
(deshalb --delete beim Verlassen von readonly, kein Ueberschreiben).
MSG
```

---

## Task 7: Der Auftrag, das Ratelimit und die Seite

**Files:**
- Create: `app/Provisioning/Jobs/SyncSeatToNextcloud.php`
- Modify: `app/Livewire/Users.php`
- Modify: `resources/views/livewire/users.blade.php`
- Modify: `lang/de/users.php`, `lang/en/users.php`
- Create: `tests/Feature/Seats/SyncSeatToNextcloudTest.php`
- Modify: `tests/Feature/…` bestehende Sitz-Prüfungen

**Interfaces:**
- Consumes: `NextcloudUsers`, `Seat::STATE_*`
- Produces: `SyncSeatToNextcloud::dispatch(string $seatUuid, string $action)` mit `action ∈ {invite, role, disable, enable}`

- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**

```php
<?php // tests/Feature/Seats/SyncSeatToNextcloudTest.php

use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Seat;
use App\Provisioning\Jobs\SyncSeatToNextcloud;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;

it('schreibt den Erfolg an den Sitz', function () {
    app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
    $customer = Customer::factory()->create();
    Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201]);
    $sitz = Seat::factory()->for($customer)->create([
        'email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld',
        'nc_state' => Seat::STATE_PENDING,
    ]);

    (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(\App\Services\Nextcloud\NextcloudUsers::class));

    expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED)
        ->and($sitz->fresh()->nc_error)->toBeNull()
        ->and($sitz->fresh()->nc_synced_at)->not->toBeNull();
});

it('schreibt den Fehlschlag mit Grund an den Sitz', function () {
    $pve = new FakeProxmoxClient;
    $pve->guestThrows[201] = new RuntimeException('guest agent unreachable');
    app()->instance(ProxmoxClient::class, $pve);
    $customer = Customer::factory()->create();
    Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201]);
    $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']);

    (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(\App\Services\Nextcloud\NextcloudUsers::class));

    expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED)
        ->and($sitz->fresh()->nc_error)->not->toBeNull();
});

it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () {
    // Nur dieser Arbeiter hat Tunnel und Proxmox-Zugangsdaten. Auf der
    // Standard-Warteschlange erreicht der Auftrag keinen einzigen Gast.
    $auftrag = new SyncSeatToNextcloud('egal', 'invite');

    expect($auftrag->connection)->toBe('provisioning')
        ->and($auftrag->queue)->toBe('provisioning');
});

it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () {
    // Anlegen ist nicht Einladen. Der Inhaber soll sein Team vorbereiten
    // koennen, ohne dass jemand eine Mail bekommt.
    Queue::fake();
    $customer = Customer::factory()->create();
    $user = $customer->ensureUser();
    Instance::factory()->for($customer)->create(['status' => 'active']);

    Livewire::actingAs($user)->test(Users::class)
        ->set('inviteEmail', 'anna@firma.tld')
        ->set('inviteName', 'Anna')
        ->call('addSeat');

    Queue::assertNothingPushed();
    expect(Seat::where('email', 'anna@firma.tld')->first()->nc_state)->toBe(Seat::STATE_NONE);
});

it('schickt erst beim Einladen einen Auftrag los', function () {
    Queue::fake();
    $customer = Customer::factory()->create();
    $user = $customer->ensureUser();
    Instance::factory()->for($customer)->create(['status' => 'active']);
    $sitz = Seat::factory()->for($customer)->create(['role' => 'member']);

    Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid);

    Queue::assertPushed(SyncSeatToNextcloud::class);
    expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING)
        ->and($sitz->fresh()->nc_username)->toBe($sitz->email);
});

it('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () {
    // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich
    // spaeter aendert, behaelt seinen Anmeldenamen.
    Queue::fake();
    $customer = Customer::factory()->create();
    $user = $customer->ensureUser();
    Instance::factory()->for($customer)->create(['status' => 'active']);
    $sitz = Seat::factory()->for($customer)->create(['email' => 'alt@firma.tld', 'nc_username' => 'alt@firma.tld']);

    $sitz->update(['email' => 'neu@firma.tld']);
    Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid);

    expect($sitz->fresh()->nc_username)->toBe('alt@firma.tld');
});

it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () {
    Queue::fake();
    $customer = Customer::factory()->create();
    $user = $customer->ensureUser();
    Instance::factory()->for($customer)->create(['status' => 'active']);
    $sitze = Seat::factory()->for($customer)->count(11)->create(['role' => 'member']);

    $seite = Livewire::actingAs($user)->test(Users::class);

    foreach ($sitze->take(10) as $sitz) {
        $seite->call('sendInvite', $sitz->uuid);
    }

    $seite->call('sendInvite', $sitze->last()->uuid);

    // Keine stumme Verweigerung: die Meldung nennt die echte Restzeit.
    $seite->assertDispatched('notify');
    expect($sitze->last()->fresh()->nc_state)->toBe(Seat::STATE_NONE);
});

it('entzieht, ohne die Zeile zu loeschen', function () {
    Queue::fake();
    $customer = Customer::factory()->create();
    $user = $customer->ensureUser();
    Instance::factory()->for($customer)->create(['status' => 'active']);
    $sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']);

    Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid);

    // Der Datensatz bleibt. Ein Fehlgriff im Userpanel darf die Arbeit eines
    // Menschen nicht vernichten — und in einem Produkt, das mit
    // Nachvollziehbarkeit verkauft wird, gaebe es danach nichts mehr zu zeigen.
    expect(Seat::find($sitz->id))->not->toBeNull()
        ->and($sitz->fresh()->status)->toBe('revoked');
    Queue::assertPushed(SyncSeatToNextcloud::class);
});

it('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () {
    // Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und
    // keine Datei. Wer das aendern will, muss diese Pruefung anfassen und
    // dabei ueber die Folgen stolpern.
    $treffer = [];

    foreach (\Symfony\Component\Finder\Finder::create()->files()->in(app_path())->name('*.php') as $datei) {
        if (str_contains($datei->getContents(), 'user:delete')) {
            $treffer[] = $datei->getRelativePathname();
        }
    }

    expect($treffer)->toBe([]);
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SyncSeatToNextcloudTest.php`
Erwartet: FEHLSCHLAG, Klasse und Methoden fehlen

- [ ] **Schritt 3: Den Auftrag schreiben**

`app/Provisioning/Jobs/SyncSeatToNextcloud.php`:

```php
<?php

namespace App\Provisioning\Jobs;

use App\Models\Seat;
use App\Services\Nextcloud\NextcloudUsers;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

/**
 * Spiegelt einen Sitz in die Nextcloud des Kunden.
 *
 * Laeuft auf der `provisioning`-Warteschlange, weil nur dieser Arbeiter den
 * Tunnel und die Proxmox-Zugangsdaten hat — das Portal hat weder das eine noch
 * das andere und darf es auch gar nicht versuchen.
 *
 * Daraus folgt: der Knopf wirkt nicht sofort. Deshalb schreibt dieser Auftrag
 * zurueck, was WIRKLICH passiert ist (`nc_state`, `nc_error`) — sonst drueckt
 * der Inhaber wieder und wieder, weil nichts sichtbar geschieht. Genau die
 * Beschwerde, die dieses Projekt schon zweimal hatte.
 *
 * `tries = 1`: ein blinder Wiederholungslauf gegen einen nicht erreichbaren
 * Gast bringt nichts, und der Inhaber hat einen Knopf dafuer.
 */
class SyncSeatToNextcloud implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;

    public function __construct(
        public string $seatUuid,
        /** invite | role | disable | enable */
        public string $action,
    ) {
        $this->onConnection('provisioning');
        $this->onQueue('provisioning');
    }

    /** Auch was an handle() vorbeifliegt, muss am Sitz sichtbar werden. */
    public function failed(?Throwable $e): void
    {
        Seat::query()->where('uuid', $this->seatUuid)->update([
            'nc_state' => Seat::STATE_FAILED,
            'nc_error' => 'unexpected',
        ]);
    }

    public function handle(NextcloudUsers $users): void
    {
        $seat = Seat::query()->with('customer')->where('uuid', $this->seatUuid)->first();

        if ($seat === null) {
            return;
        }

        // Erneut geprueft, nicht der Seite geglaubt: eine geschlossene Instanz
        // kann ihre VMID auf demselben Host weiterverliehen haben — dieselbe
        // Falle, die in IssueInstanceAdminAccess schon beschrieben steht.
        $instance = $seat->customer?->instances()
            ->whereIn('status', ['active', 'cancellation_scheduled'])
            ->latest('id')->first();

        if ($instance === null || $instance->host === null || blank($instance->vmid)) {
            $this->record($seat, false, 'no_instance');

            return;
        }

        $ok = match ($this->action) {
            'invite' => $users->invite($instance, $seat) && $users->applyRole($instance, $seat),
            'role' => $users->applyRole($instance, $seat),
            'disable' => $users->disable($instance, $seat),
            'enable' => $users->enable($instance, $seat),
            default => false,
        };

        $this->record($seat, $ok, $ok ? null : 'guest_failed');
    }

    private function record(Seat $seat, bool $ok, ?string $grund): void
    {
        $seat->forceFill([
            'nc_state' => $ok ? Seat::STATE_SYNCED : Seat::STATE_FAILED,
            'nc_error' => $grund,
            'nc_synced_at' => $ok ? now() : $seat->nc_synced_at,
        ])->save();
    }
}
```

- [ ] **Schritt 4: `app/Livewire/Users.php` umbauen**

Die bestehende `invite()` wird zu **`addSeat()`** — sie legt nur an, schickt nichts los und lässt `nc_state` auf `none`. Die Sitzplatzgrenze, die Sperre gegen doppelte Adressen und die Transaktion mit `lockForUpdate()` bleiben **unverändert**.

Neu daneben:

```php
    /**
     * Der Inhaber-Sitz wird beim Anlegen mit dem bestehenden Admin-Konto
     * verknuepft.
     *
     * `mount()` legt beim ersten Besuch den owner-Sitz an. Dieses Konto gibt es
     * in der Nextcloud laengst — CreateCustomerAdmin hat es beim Aufbau
     * angelegt. Ohne diese Verknuepfung stuende der Sitz auf `none`, und das
     * Panel boete dem Inhaber an, sich SELBST einzuladen; der Auftrag traefe
     * dann auf einen Benutzer, den es schon gibt.
     *
     * Die Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand — sie fuehrt
     * ihre eigene, eingefrorene Fassung. Hier gilt die laufende.
     */
    // In mount(), direkt nach dem firstOrCreate des owner-Sitzes:
    //     $customer->seats()->where('role', 'owner')->first()?->linkToInstanceAdmin();

    /**
     * Einladen — der zweite, getrennte Schritt.
     *
     * Anlegen und Einladen sind ausdruecklich zwei Vorgaenge: ein Inhaber
     * soll sein Team vorbereiten und die Einladungen spaeter verschicken
     * koennen, etwa alle am ersten Arbeitstag.
     */
    public function sendInvite(string $uuid): void
    {
        $customer = $this->requireCustomer();

        if ($customer === null) {
            return;
        }

        $seat = $customer->seats()->where('uuid', $uuid)->first();

        if ($seat === null) {
            return;
        }

        if (($warten = $this->rateLimited($customer, $seat)) !== null) {
            $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten]));

            return;
        }

        // Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer nicht
        // umbenennen. Ein Sitz, dessen Adresse sich spaeter aendert, behaelt
        // seinen Anmeldenamen.
        if (blank($seat->nc_username)) {
            $seat->nc_username = $seat->email;
        }

        $seat->forceFill([
            'nc_username' => $seat->nc_username,
            'status' => 'invited',
            'invited_at' => now(),
            'nc_state' => Seat::STATE_PENDING,
            'nc_error' => null,
        ])->save();

        SyncSeatToNextcloud::dispatch($seat->uuid, 'invite');

        $this->dispatch('notify', message: __('users.invite_sent'));
    }

    /**
     * Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde
     * gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdruecken
     * an derselben Zeile.
     *
     * Gibt die Restzeit in Minuten zurueck, oder null wenn frei. Eine stumme
     * Verweigerung waere schlimmer als die Grenze selbst.
     */
    private function rateLimited(Customer $customer, Seat $seat): ?int
    {
        foreach ([
            ['seat-invite:customer:'.$customer->id, 10],
            ['seat-invite:seat:'.$seat->id, 3],
        ] as [$schluessel, $grenze]) {
            if (RateLimiter::tooManyAttempts($schluessel, $grenze)) {
                return (int) ceil(RateLimiter::availableIn($schluessel) / 60);
            }
        }

        RateLimiter::increment('seat-invite:customer:'.$customer->id, 3600);
        RateLimiter::increment('seat-invite:seat:'.$seat->id, 3600);

        return null;
    }
```

`setRole()` bekommt nach dem `update()` einen Auftrag:

```php
        $seat->update(['role' => $role]);
        $this->queueSync($seat, 'role');
```

`suspend()` und `revoke()` ebenso — und `revoke()` **löscht nicht mehr**:

```php
            // Nicht loeschen. Der Zugang ist zu, die Arbeit bleibt dort, wo
            // sein Team sie braucht. Wer wirklich loeschen will, tut das in
            // der Nextcloud, wo Nextcloud danach fragt, was mit den Dateien
            // geschehen soll.
            $seat->update(['status' => 'revoked']);
```

Dazu der gemeinsame Helfer:

```php
    /**
     * Ein Sitz, der nie in der Nextcloud war, braucht keinen Auftrag — es
     * gaebe dort nichts zu aendern.
     */
    private function queueSync(Seat $seat, string $action): void
    {
        if ($seat->nc_state === Seat::STATE_NONE) {
            return;
        }

        $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save();
        SyncSeatToNextcloud::dispatch($seat->uuid, $action);
    }
```

- [ ] **Schritt 5: Die Ansicht um die Zustände erweitern**

In `resources/views/livewire/users.blade.php` bekommt jede Zeile eine Zustandsspalte:

```blade
<td class="px-4 py-3">
    @if ($seat->nc_state === \App\Models\Seat::STATE_NONE)
        <x-ui.badge status="info">{{ __('users.state_none') }}</x-ui.badge>
    @elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING)
        <x-ui.badge status="info">{{ __('users.state_pending') }}</x-ui.badge>
    @elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
        <x-ui.badge status="error">{{ __('users.state_failed') }}</x-ui.badge>
        <p class="mt-1 text-xs text-muted">{{ __('users.error_'.$seat->nc_error) }}</p>
        <button type="button" wire:click="retry('{{ $seat->uuid }}')" class="mt-1 text-xs underline">
            {{ __('users.retry') }}
        </button>
    @else
        <x-ui.badge status="{{ $seat->status === 'active' ? 'active' : 'info' }}">
            {{ __('users.status_'.$seat->status) }}
        </x-ui.badge>
    @endif
</td>
```

Und bei der Rollenauswahl der ehrliche Text zu `readonly`:

```blade
<p class="mt-1 text-xs text-muted">{{ __('users.role_readonly_means') }}</p>
```

- [ ] **Schritt 6: Sprachdateien**

`lang/de/users.php`:

```php
    'state_none' => 'angelegt — noch nicht eingeladen',
    'state_pending' => 'wird eingerichtet …',
    'state_failed' => 'fehlgeschlagen',
    'retry' => 'Nochmal versuchen',
    'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.',
    'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.',
    'error_no_instance' => 'Die Cloud dieses Kunden ist gerade nicht erreichbar.',
    'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.',
    'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.',
    'role_readonly_means' => 'Kann nichts hochladen oder anlegen. Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten.',
```

`lang/en/users.php` mit denselben Schlüsseln auf Englisch.

- [ ] **Schritt 7: Laufen lassen, grün**

Ausführen:
```bash
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/
```
Erwartet: alle grün

- [ ] **Schritt 8: Die ganze Suite**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test`
Erwartet: alles grün. Bestehende Prüfungen, die `invite()` aufrufen oder erwarten, dass `revoke()` die Zeile entfernt, werden mitgezogen — mit einer Notiz im Test, warum sich das Verhalten geändert hat.

- [ ] **Schritt 9: Festschreiben**

```bash
git commit -F- <<'MSG'
Aus der Attrappe wird Verwaltung

Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.

Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.

Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
MSG
```

---

## Task 8: Der Nachweis gegen echte Hardware

**Files:**
- Create: `docs/runbooks/mitarbeiter-nachweis.md`

Diese Aufgabe schreibt **keinen** Code. Sie beweist zwei Dinge, die sich gegen einen Fake nicht beweisen lassen — und die den Entwurf umwerfen, wenn sie nicht stimmen.

- [ ] **Schritt 1: Voraussetzung prüfen**

Der Mailserver `mail.clupilot.cloud` muss stehen, das Postfach `instance-relay` in der Konsole angelegt und der Versandtest dort grün sein.

Ausführen: `php artisan clupilot:configure-instance-mail --dry-run`
Erwartet: die Liste der Instanzen, die nachgetragen würden.

- [ ] **Schritt 2: Eine Testinstanz nachrüsten**

Ausführen: `php artisan clupilot:configure-instance-mail --instance=<uuid>`
Erwartet: Lauf gestartet, Pipeline `instance-mail` in der Konsole grün.

- [ ] **Schritt 3: Nachweis A — kommt die Einladung an?**

Im Userpanel einen Mitarbeiter anlegen und einladen. Erwartet: eine Mail an dessen Adresse, Absender `noreply@clupilot.cloud`, mit einem Link zum Setzen des Passworts. Der Link führt in **die Nextcloud des Kunden**, nicht ins Portal.

Kommt keine Mail: in der Nextcloud `occ config:system:get mail_smtphost` prüfen und im mailcow-Protokoll nachsehen, ob die Verbindung überhaupt ankam. Der häufigste Grund ist die Sperre auf Port 587 — die Hostadresse muss in der Zulassungsliste stehen.

- [ ] **Schritt 4: Nachweis B — bedeutet `0 B` wirklich null?**

**Das ist der Nachweis, der den Entwurf umwerfen kann.** Aus der Dokumentation ist nicht zu belegen, ob Nextcloud `0 B` als „null Bytes" oder als „unbegrenzt" auslegt.

Einen Sitz auf `readonly` stellen, dann in der Nextcloud als dieser Benutzer anmelden und eine Datei hochladen versuchen.

- **Erwartet:** der Upload wird abgewiesen.
- **Fällt es anders aus:** `readonly` liefert keine Beschränkung. Dann wird der Speicherplatzteil aus `NextcloudUsers::applyRole()` entfernt, die Rolle behält nur ihre Gruppe, und der Text `users.role_readonly_means` wird auf das reduziert, was dann noch stimmt — die Beschränkung läuft über Freigaberechte. **Nicht** stehen lassen und hoffen.

- [ ] **Schritt 5: Das Ergebnis aufschreiben**

`docs/runbooks/mitarbeiter-nachweis.md` mit beiden Ergebnissen, Datum, Nextcloud-Fassung. Ein Nachweis, den niemand aufschreibt, wird beim nächsten Zweifel erneut geführt.

- [ ] **Schritt 6: Festschreiben**

```bash
git commit -F- -- docs/runbooks/mitarbeiter-nachweis.md <<'MSG'
Nachweis gegen echte Hardware: Einladung und Speicherplatz

Zwei Dinge, die sich gegen einen Fake nicht beweisen lassen. Der zweite kann
den Entwurf umwerfen: ob Nextcloud "0 B" als null oder als unbegrenzt
auslegt, steht in keiner Dokumentation.
MSG
```

---

## Selbstdurchsicht

**Abdeckung des Entwurfs**

| Anforderung | Aufgabe |
|---|---|
| Mailversand im Gast, eigene Absenderdomain | 1, 3 |
| Passwort nicht in der Prozessliste der VM | 2, 3 |
| Bestand nachrüsten | 4 |
| Anlegen ≠ Einladen | 7 |
| Nextcloud erzeugt das Passwort, niemand kennt es | 6 |
| Erneut einladen / Passwort zurücksetzen | 6 (`user:welcome --reset-password`) |
| Benutzername einmal gesetzt, nie geändert | 5, 7 |
| Inhaber-Sitz = bestehendes Admin-Konto | 5 |
| Rollen als Gruppen, vier Stück | 5, 6 |
| `readonly` ehrlich beschriftet + Nachweis | 6, 7, 8 |
| Speicherplatz beim Verlassen löschen, nicht setzen | 6 |
| Sperren sofort wirksam | 6 |
| Entziehen löscht nichts | 7 |
| Ratelimit 10/Kunde, 3/Sitz | 7 |
| Absicht/Wirklichkeit getrennt, Fehler sichtbar | 5, 7 |
| Sicherheitsgrenzen (fremder Sitz, erneute Prüfung im Auftrag) | 6, 7 |
| `user:delete` verboten | 7 |

Keine Lücke.

**Namensgleichheit über die Aufgaben**

`GuestMailConfig::for()/available()/problem()/values()/password()` (1) → benutzt in 3.
`NextcloudOcc` bleibt unveraendert (Aufgabe 2 entfaellt) — Aufgabe 3 benutzt
das bestehende `command()`.
`Seat::STATE_*`, `Seat::GROUPS`, `isReadonly()`, `linkToInstanceAdmin()` (5) → benutzt in 6, 7.
`NextcloudUsers::invite/applyRole/disable/enable` (6) → benutzt in 7.
`SyncSeatToNextcloud(string $seatUuid, string $action)` (7) → in sich geschlossen.

**Offene Stelle, bewusst so**

Aufgabe 8 hängt an Infrastruktur, die der Betreiber erst aufsetzt. Die Aufgaben 1–7 sind davon unabhängig prüfbar und können vollständig gebaut werden, bevor der Mailserver steht.
