diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php
index 563985d..1080a45 100644
--- a/app/Livewire/Admin/Settings.php
+++ b/app/Livewire/Admin/Settings.php
@@ -237,47 +237,113 @@ class Settings extends Component
return;
}
- // Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht.
+ // Alles, was diese Einladung anlegt, in EINER Transaktion.
//
- // Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal
- // angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für
- // einen Zugang zur Konsole, die die ganze Flotte und jede
- // Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen,
- // gehört keinem von beiden.
- //
- // Die Zufallszeichenkette unten ist deshalb kein „temporäres
- // Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird
- // vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner
- // Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird
- // das Konto erst, wenn der Eingeladene über den Link sein eigenes
- // Passwort vergibt (App\Livewire\Auth\OperatorSetPassword).
- $newStaff = Operator::create([
- 'name' => $data['staffName'],
- 'email' => $data['staffEmail'],
- 'password' => Str::random(64),
- ]);
- $newStaff->assignRole($data['staffRole']);
+ // Vorher liefen Operator::create() und assignRole() fest, bevor der
+ // Broker den Token zog und die Mail hinausging. Ging die Datenbank
+ // oder Redis genau dazwischen kurz weg, blieb ein Konto stehen, das
+ // nie eine Einladung bekommen hatte — und `unique:operators,email`
+ // blockierte seine Adresse danach für immer, ohne einen Rückweg
+ // ausser der Shell. Eine vorübergehende Störung darf keinen
+ // dauerhaften Schaden hinterlassen: bricht hier irgendetwas ab, rollt
+ // die Transaktion Konto, Rolle und Token gemeinsam zurück.
+ DB::transaction(function () use ($data, $operator) {
+ // Ein Konto, dessen Passwort niemand kennt — auch der Inhaber nicht.
+ //
+ // Hier wurde bis zuletzt ein Passwort erzeugt und dem Inhaber einmal
+ // angezeigt, damit er es „sicher weitergibt": per Chat, per Zuruf. Für
+ // einen Zugang zur Konsole, die die ganze Flotte und jede
+ // Kundeninstanz verwaltet. Ein Passwort, das zwei Menschen kennen,
+ // gehört keinem von beiden.
+ //
+ // Die Zufallszeichenkette unten ist deshalb kein „temporäres
+ // Passwort", sondern das Gegenteil: sie entsteht in dieser Zeile, wird
+ // vom `hashed`-Cast verhasht und ist danach fort — sie steht in keiner
+ // Eigenschaft, in keiner Mail und in keinem Protokoll. Anmeldbar wird
+ // das Konto erst, wenn der Eingeladene über den Link sein eigenes
+ // Passwort vergibt (App\Livewire\Auth\OperatorSetPassword).
+ $newStaff = Operator::create([
+ 'name' => $data['staffName'],
+ 'email' => $data['staffEmail'],
+ 'password' => Str::random(64),
+ ]);
+ $newStaff->assignRole($data['staffRole']);
- // Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener
- // Provider (R21, siehe config/auth.php). Nicht sendResetLink() —
- // das verschickt Laravels eigene Benachrichtigung mit einem Link auf
- // die Seite des PORTALS und wäre genau die Vermischung, die diese
- // Trennung beendet hat.
- $token = Password::broker('operators')->createToken($newStaff);
+ // Der Token kommt vom BETREIBER-Broker: eigene Tabelle, eigener
+ // Provider (R21, siehe config/auth.php). Nicht sendResetLink() —
+ // das verschickt Laravels eigene Benachrichtigung mit einem Link auf
+ // die Seite des PORTALS und wäre genau die Vermischung, die diese
+ // Trennung beendet hat.
+ $token = Password::broker('operators')->createToken($newStaff);
- $mail = new OperatorInvitationMail(
- $newStaff,
- $operator->name,
- route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]),
- OperatorInvitationMail::validHours(),
- );
- Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail);
+ $mail = new OperatorInvitationMail(
+ $newStaff,
+ $operator->name,
+ route('admin.invitation', ['token' => $token, 'email' => $newStaff->email]),
+ OperatorInvitationMail::validHours(),
+ );
+ Mail::mailer($mail->mailer)->to($newStaff->email)->queue($mail);
+ });
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
}
+ /**
+ * Ein neues Einladungs-Token für ein Konto, das noch niemand benutzt hat.
+ *
+ * Für den Vierte-Tag-Fall: der Link war 72 Stunden gültig, Sam war länger
+ * weg. `unique:operators,email` verweigert danach eine zweite Einladung
+ * an dieselbe Adresse — das Konto existiert ja schon —, und es gab bisher
+ * keinen Weg zurück ausser der Shell (clupilot:create-operator), die dabei
+ * gleich auf `Owner` setzt. Eine Rettung aus einer abgelaufenen
+ * Support-Einladung, die den Betroffenen zum Inhaber macht, ist keine
+ * Rettung.
+ *
+ * Legt bewusst KEIN neues Konto an — nur einen neuen Token für das
+ * bestehende. `createToken()` löscht die alte Token-Zeile von sich aus,
+ * bevor es die neue einfügt (operator_password_reset_tokens hat die
+ * Adresse als Primärschlüssel, siehe DatabaseTokenRepository::create()):
+ * der alte Link verliert dadurch seine Gültigkeit, ohne dass hier extra
+ * aufgeräumt werden müsste.
+ */
+ public function resendInvitation(int $id): void
+ {
+ $this->authorize('staff.manage');
+
+ if (! $operator = $this->currentOperator()) {
+ return;
+ }
+
+ $target = Operator::query()->whereKey($id)->first();
+
+ // Nur für jemanden, der sich noch nie angemeldet hat. `last_login_at`
+ // bleibt null, bis OperatorLogin::completeLogin() einmal durchläuft,
+ // und wird sonst nirgends gesetzt — der einzige Bestand, an dem sich
+ // „hat den Link noch nie benutzt" von „arbeitet hier längst" verlässlich
+ // unterscheiden lässt. Ein Knopf, der einem angemeldeten Kollegen das
+ // Passwort neu vergeben liesse, wäre ein Fehler, kein Komfort — und
+ // schützt nebenbei auch vor der eigenen Kachel: die Person, die diesen
+ // Knopf drückt, ist selbst angemeldet und hat also selbst schon ein
+ // gesetztes `last_login_at`.
+ if ($target === null || ! $target->isOperator() || $target->last_login_at !== null) {
+ return;
+ }
+
+ $token = Password::broker('operators')->createToken($target);
+
+ $mail = new OperatorInvitationMail(
+ $target,
+ $operator->name,
+ route('admin.invitation', ['token' => $token, 'email' => $target->email]),
+ OperatorInvitationMail::validHours(),
+ );
+ Mail::mailer($mail->mailer)->to($target->email)->queue($mail);
+
+ $this->dispatch('notify', message: __('admin_settings.invitation_resent'));
+ }
+
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
@@ -631,6 +697,10 @@ class Settings extends Component
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === Auth::guard('operator')->id(),
+ // Der Beleg für „hat den Einladungslink noch nie benutzt": wird
+ // ausschliesslich bei einer echten Anmeldung gesetzt (siehe
+ // OperatorLogin::completeLogin), nicht beim Anlegen des Kontos.
+ 'never_signed_in' => $u->last_login_at === null,
]);
return view('livewire.admin.settings', [
diff --git a/app/Livewire/Auth/OperatorSetPassword.php b/app/Livewire/Auth/OperatorSetPassword.php
index bc61f4c..bc3223d 100644
--- a/app/Livewire/Auth/OperatorSetPassword.php
+++ b/app/Livewire/Auth/OperatorSetPassword.php
@@ -95,11 +95,13 @@ class OperatorSetPassword extends Component
if ($status !== Password::PASSWORD_RESET) {
// EINE Meldung für alle Fehlschläge, und ausdrücklich nicht
- // __($status): Laravels Statusschlüssel (passwords.token,
- // passwords.user) haben in dieser Installation keine
- // Übersetzungsdatei und stünden roh als „passwords.token" auf der
- // Seite. Sie zu unterscheiden wäre ohnehin falsch — „diese Adresse
- // kennen wir nicht" verrät einem Fremden, wer Betreiber ist.
+ // __($status): NICHT weil passwords.token/passwords.user roh auf
+ // der Seite stünden — Laravel liefert dazu eine eigene
+ // Übersetzung mit (lang/en/passwords.php im Framework, und
+ // fallback_locale ist en), der Schlüssel löst also durchaus auf.
+ // Der eigentliche Grund ist ein anderer: die Fehlschläge zu
+ // unterscheiden wäre falsch — „diese Adresse kennen wir nicht"
+ // verrät einem Fremden, wer Betreiber ist.
$this->addError('email', __('auth.console_invite_invalid'));
return;
diff --git a/config/auth.php b/config/auth.php
index 90b2fe5..92e26d8 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -136,11 +136,19 @@ return [
// Kommt später eine Seite „Passwort vergessen" für Betreiber dazu,
// gehört ihr eine kürzere Frist — dann braucht sie einen eigenen
// Broker über derselben Tabelle, statt diese Zahl zu verbiegen.
+ //
+ // Kein `throttle` hier: der einzige Leser dieses Werts ist
+ // sendResetLink() (DatabaseTokenRepository::recentlyCreatedToken()),
+ // und dieser Broker stellt Token ausschliesslich über createToken()
+ // aus — inviteStaff() und resendInvitation() in Admin\Settings.
+ // sendResetLink() würde ohnehin an Operator::sendPasswordResetNotification()
+ // scheitern, das absichtlich wirft (R21: kein Selbstbedienungs-Weg für
+ // Betreiber). Fehlt der Schlüssel, liest PasswordBrokerManager 0 —
+ // ohne Wirkung, weil der Pfad, der ihn braucht, nie läuft.
'operators' => [
'provider' => 'operators',
'table' => 'operator_password_reset_tokens',
'expire' => 4320,
- 'throttle' => 60,
],
],
diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php
index c545cf6..912b120 100644
--- a/lang/de/admin_settings.php
+++ b/lang/de/admin_settings.php
@@ -31,6 +31,11 @@ return [
// Broker-Einstellung, damit hier nicht eine zweite Frist behauptet wird.
'invite_hint' => 'Wir schicken eine Einladung an diese Adresse. Der Link darin gilt :hours Stunden; das Passwort vergibt der Mitarbeiter selbst — niemand hier bekommt es zu sehen.',
'staff_invited' => 'Einladung verschickt.',
+ // Nur solange der alte Link noch nie benutzt wurde (Settings::render(),
+ // never_signed_in) — der Rückweg aus einer abgelaufenen Einladung, ohne
+ // die Shell und ohne dabei zum Inhaber zu werden.
+ 'resend_invitation' => 'Einladung erneut senden',
+ 'invitation_resent' => 'Einladung erneut verschickt.',
'col_person' => 'Person',
'col_actions' => 'Aktionen',
diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php
index 9a31bab..d7b9375 100644
--- a/lang/en/admin_settings.php
+++ b/lang/en/admin_settings.php
@@ -28,6 +28,11 @@ return [
'invite' => 'Invite',
'invite_hint' => 'We send an invitation to this address. The link in it is valid for :hours hours; the staff member sets their own password — nobody here gets to see it.',
'staff_invited' => 'Invitation sent.',
+ // Only while the old link has never been used (Settings::render(),
+ // never_signed_in) — the way back from an expired invitation, without the
+ // shell and without becoming the Owner along the way.
+ 'resend_invitation' => 'Resend invitation',
+ 'invitation_resent' => 'Invitation resent.',
'col_person' => 'Person',
'col_actions' => 'Actions',
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php
index a16b11a..1472bbf 100644
--- a/resources/views/livewire/admin/settings.blade.php
+++ b/resources/views/livewire/admin/settings.blade.php
@@ -619,7 +619,21 @@
—
@endif
@unless ($s['self'])
-
+
+ {{-- Nur, solange der Link noch nie benutzt wurde
+ (siehe Settings::render(), never_signed_in): wer
+ schon angemeldet war, braucht kein neues Token,
+ und ein Knopf, der einem arbeitenden Kollegen das
+ Passwort neu vergeben liesse, wäre ein Fehler. --}}
+ @if ($s['never_signed_in'])
+
+ @endif
+
+
@endunless
diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php
index 7a43175..a6e5f4f 100644
--- a/tests/Feature/Admin/AdminSettingsTest.php
+++ b/tests/Feature/Admin/AdminSettingsTest.php
@@ -85,6 +85,22 @@ it('will not escalate a non-staff user via a tampered id', function () {
expect(Operator::role('Admin')->count())->toBe($adminsBefore);
});
+it('forbids a non-Owner from resending an invitation', function () {
+ // Same capability as inviting in the first place — a resend is a token
+ // for an account nobody has used yet, and issuing one is not a lesser
+ // action than creating the account was.
+ Mail::fake();
+
+ Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
+ ->call('inviteStaff')->assertHasNoErrors();
+
+ $sam = Operator::query()->where('email', 'sam@ops.test')->firstOrFail();
+
+ Livewire::actingAs(operator('Admin'), 'operator')->test(Settings::class)
+ ->call('resendInvitation', $sam->id)->assertForbidden();
+});
+
it('protects the last owner and self-role', function () {
$owner = operator('Owner');
$support = operator('Support');
diff --git a/tests/Feature/Auth/OperatorInvitationTest.php b/tests/Feature/Auth/OperatorInvitationTest.php
index 6a413af..3f22b56 100644
--- a/tests/Feature/Auth/OperatorInvitationTest.php
+++ b/tests/Feature/Auth/OperatorInvitationTest.php
@@ -46,6 +46,57 @@ function invite(string $email = 'sam@ops.test', string $role = 'Support'): Opera
return Operator::query()->where('email', $email)->firstOrFail();
}
+/**
+ * Jede nichtleere Zeichenkette, die unter den öffentlichen Eigenschaften
+ * einer Komponente steckt — auch dann, wenn sie in einem Feld liegt statt
+ * selbst eine Eigenschaft zu sein.
+ *
+ * Die naheliegendste Rückkehr des alten Kastens war kein String, sondern
+ * genau das: `public array $invited = ['email' => …, 'password' => $temp]`.
+ * Eine Prüfung, die nur `is_string($wert)` zulässt und bei allem anderen
+ * `continue`t, liefe an so einem Feld lautlos vorbei — sie hätte den
+ * historischen Fehler selbst nicht gefunden.
+ *
+ * @return array
+ */
+function publicStrings(object $instanz): array
+{
+ $gefunden = [];
+
+ $einsammeln = function ($wert) use (&$gefunden, &$einsammeln) {
+ if (is_string($wert) && $wert !== '') {
+ $gefunden[] = $wert;
+ } elseif (is_array($wert)) {
+ foreach ($wert as $teil) {
+ $einsammeln($teil);
+ }
+ }
+ };
+
+ foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) {
+ $einsammeln($eigenschaft->getValue($instanz));
+ }
+
+ return $gefunden;
+}
+
+/**
+ * Jedes Wort ab acht Zeichen aus einem gerenderten Text gegen einen
+ * Passwort-Hash gehalten — dasselbe Vorgehen, das für die Mail schon gilt
+ * (siehe unten, „trägt kein Passwort in der Mail"), hier auf HTML statt auf
+ * eine Mailable angewandt. Träfe irgendwo ein Passwort auf den Bildschirm —
+ * im Fliesstext, in einem `value`-Attribut, in einem versteckten Feld —,
+ * schlüge genau eines dieser Wörter an.
+ */
+function assertNoPasswordOnScreen(string $html, string $hash): void
+{
+ preg_match_all('/[^\s<>"\']{8,}/', strip_tags($html), $treffer);
+
+ foreach ($treffer[0] as $wort) {
+ expect(Hash::check($wort, $hash))->toBeFalse("[{$wort}] steht als Passwort auf dem Bildschirm");
+ }
+}
+
// ---- Was das Konto beim Anlegen ist: noch niemandes ----
it('legt ein Konto an, in das sich niemand anmelden kann', function () {
@@ -67,12 +118,16 @@ it('legt ein Konto an, in das sich niemand anmelden kann', function () {
expect(auth('operator')->check())->toBeFalse();
});
-it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', function () {
+it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite und nicht auf dem Bildschirm', function () {
// Der eigentliche Fehler war nicht das Passwort, sondern dass es auf einem
// Bildschirm stand. Deshalb wird hier nicht auf die zwei gelöschten
// Eigenschaften geprüft (die kämen unter anderem Namen wieder), sondern
// darauf, dass KEINE öffentliche Eigenschaft dieser Seite das Passwort des
- // eben angelegten Kontos ist.
+ // eben angelegten Kontos ist — auch nicht als Feld in einem Array, so wie
+ // es der alte Kasten war (`public array $invited = [..., 'password' => …]`).
+ // Und, weil eine Eigenschaftsprüfung allein nichts über den tatsächlichen
+ // Bildschirm beweist: dasselbe Vorgehen wie bei der Mail zusätzlich über
+ // das gerenderte Ergebnis — genau da stand der historische Fehler.
Mail::fake();
$seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
@@ -82,17 +137,13 @@ it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', funct
$ivy = Operator::query()->where('email', 'ivy@ops.test')->firstOrFail();
$instanz = $seite->instance();
- foreach ((new ReflectionObject($instanz))->getProperties(ReflectionProperty::IS_PUBLIC) as $eigenschaft) {
- $wert = $eigenschaft->getValue($instanz);
-
- if (! is_string($wert) || $wert === '') {
- continue;
- }
-
+ foreach (publicStrings($instanz) as $wert) {
expect(Hash::check($wert, $ivy->password))->toBeFalse(
- "Die Eigenschaft {$eigenschaft->getName()} ist das Passwort des neuen Kontos.",
+ "Ein Eigenschaftswert der Seite ist das Passwort des neuen Kontos: {$wert}",
);
}
+
+ assertNoPasswordOnScreen($seite->html(), $ivy->password);
});
// ---- Was hinausgeht: ein Link, kein Passwort ----
@@ -318,8 +369,10 @@ it('hält die Frist in Tagen, nicht in Monaten', function () {
});
it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function () {
- // `passwords.token` hat in dieser Installation keine Übersetzungsdatei und
- // stünde roh mitten auf der Seite.
+ // Nicht weil `passwords.token` roh auf der Seite stünde — Laravel liefert
+ // dazu eine eigene Übersetzung mit (siehe OperatorSetPassword::save()) —,
+ // sondern weil EINE Meldung für alle Fehlschläge gilt: sie zu
+ // unterscheiden verriete einem Fremden, wer Betreiber ist.
Mail::fake();
invite();
@@ -330,7 +383,10 @@ it('nennt keinen rohen Statusschlüssel, wenn der Link nicht gilt', function ()
->set('password_confirmation', 'ein-langes-neues-passwort')
->call('save')
->assertHasErrors('email')
- ->assertSee(__('auth.console_invite_invalid'));
+ ->assertSee(__('auth.console_invite_invalid'))
+ // Die Zusicherung, die der Name tatsächlich verspricht: stünde
+ // `passwords.token` daneben, bliebe der Test oben trotzdem grün.
+ ->assertDontSee('passwords.');
});
// ---- Wo der Link hinführt: in die Konsole, hinter ihre Schranken ----
@@ -367,18 +423,138 @@ it('führt die Einladung über eine Route der Konsole, nicht über eine des Port
it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () {
Mail::fake();
- $html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
->set('tab', 'team')
->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support')
- ->call('inviteStaff')
- ->html();
+ ->call('inviteStaff');
- // Die drei Zeilen, die den Kasten beschriftet haben, gibt es nicht mehr —
- // in keiner der beiden Sprachen.
+ $sam = Operator::query()->where('email', 'sam@ops.test')->firstOrFail();
+ $html = $seite->html();
+
+ // Die drei Zeilen, die den alten Kasten beschriftet haben, gibt es nicht
+ // mehr — in keiner der beiden Sprachen. Das allein beweist aber nichts:
+ // ein Kasten unter NEUEN Schlüsseln käme durch drei fehlende alte
+ // Schlüssel unbemerkt durch. Der Name dieses Tests verspricht eine
+ // Aussage über die SEITE, nicht über drei bestimmte Übersetzungsdateien.
foreach (['admin_settings.temp_title', 'admin_settings.temp_hint', 'admin_settings.temp_password'] as $schlüssel) {
expect(Lang::has($schlüssel, 'de'))->toBeFalse("{$schlüssel} steht noch in lang/de")
->and(Lang::has($schlüssel, 'en'))->toBeFalse("{$schlüssel} steht noch in lang/en");
}
+ // Die Aussage, die der Name tatsächlich verspricht: der Bildschirm selbst
+ // enthält kein Zugangsdatum, gleich unter welchem Schlüssel es aufgetaucht
+ // wäre.
+ assertNoPasswordOnScreen($html, $sam->password);
+
expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()])));
});
+
+// ---- Der Rückweg: läuft der Link ab, ist die Adresse nicht verbrannt ----
+
+it('bietet „Einladung erneut senden" nur für jemanden an, der den Link noch nie benutzt hat', function () {
+ // Sam hat sich nie angemeldet: last_login_at ist null, wie bei jedem
+ // frisch eingeladenen Konto (OperatorLogin::completeLogin ist der einzige
+ // Ort, der das Feld setzt). Ivy dagegen ist längst im Team — ein Knopf,
+ // der ihr das Passwort neu vergäbe, wäre ein Fehler, kein Komfort.
+ Mail::fake();
+
+ $sam = invite();
+ $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]);
+
+ $html = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ ->set('tab', 'team')
+ ->html();
+
+ expect($html)->toContain("resendInvitation({$sam->id})")
+ ->and($html)->not->toContain("resendInvitation({$ivy->id})");
+});
+
+it('stellt beim erneuten Senden ein neues Token aus und macht das alte damit ungültig', function () {
+ Mail::fake();
+
+ $sam = invite();
+ $altesToken = Password::broker('operators')->createToken($sam);
+
+ Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ ->call('resendInvitation', $sam->id)
+ ->assertHasNoErrors();
+
+ // Der Broker löscht die bestehende Token-Zeile beim Ausstellen einer neuen
+ // von selbst — operator_password_reset_tokens hat die Adresse als
+ // Primärschlüssel (DatabaseTokenRepository::create() ruft deleteExisting()
+ // vor dem Insert). Das alte Token verliert dadurch von selbst seine
+ // Gültigkeit, ohne dass Admin\Settings hier extra aufräumen müsste.
+ expect(DB::table('operator_password_reset_tokens')->where('email', 'sam@ops.test')->count())->toBe(1);
+
+ Livewire::test(OperatorSetPassword::class, ['token' => $altesToken])
+ ->set('email', 'sam@ops.test')
+ ->set('password', 'ein-langes-neues-passwort')
+ ->set('password_confirmation', 'ein-langes-neues-passwort')
+ ->call('save')
+ ->assertSet('done', false)
+ ->assertHasErrors('email');
+
+ expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeFalse();
+});
+
+it('lässt sich mit dem frischen Token aus einer erneut gesendeten Einladung anmelden', function () {
+ Mail::fake();
+
+ $sam = invite();
+
+ Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ ->call('resendInvitation', $sam->id)
+ ->assertHasNoErrors();
+
+ Mail::assertQueued(OperatorInvitationMail::class, fn (OperatorInvitationMail $mail) => $mail->hasTo('sam@ops.test'));
+
+ $frisch = Password::broker('operators')->createToken($sam);
+
+ Livewire::test(OperatorSetPassword::class, ['token' => $frisch])
+ ->set('email', 'sam@ops.test')
+ ->set('password', 'ein-langes-neues-passwort')
+ ->set('password_confirmation', 'ein-langes-neues-passwort')
+ ->call('save')
+ ->assertHasNoErrors()
+ ->assertSet('done', true);
+
+ expect(Hash::check('ein-langes-neues-passwort', $sam->fresh()->password))->toBeTrue();
+});
+
+it('bietet den Rückweg nicht für jemanden an, der sich längst angemeldet hat', function () {
+ // Ein direkter Aufruf, an der Oberfläche vorbei: der Knopf ist zwar
+ // ausgeblendet, aber die Livewire-Methode ist ein öffentlicher Endpunkt
+ // wie jede andere. Ein bereits benutztes Konto darf sein Passwort nicht
+ // über diesen Weg neu bekommen.
+ Mail::fake();
+
+ $ivy = Operator::factory()->role('Support')->create(['last_login_at' => now()]);
+ $vorherigesPasswort = $ivy->password;
+
+ Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class)
+ ->call('resendInvitation', $ivy->id);
+
+ Mail::assertNothingQueued();
+ expect(DB::table('operator_password_reset_tokens')->where('email', $ivy->email)->exists())->toBeFalse()
+ ->and($ivy->fresh()->password)->toBe($vorherigesPasswort);
+});
+
+it('lässt beim Einladen kein halbes Konto zurück, wenn der Mailversand scheitert', function () {
+ // Vorher liefen Operator::create() und assignRole() fest, bevor der
+ // Broker den Token zog und die Mail hinausging. Geht die
+ // Mail-Warteschlange GENAU dazwischen kurz weg — Redis kurz nicht
+ // erreichbar —, blieb bisher ein Konto stehen, das nie eine Einladung
+ // bekommen hatte: unique:operators,email hätte jede erneute Einladung an
+ // dieselbe Adresse für immer abgewiesen.
+ Mail::shouldReceive('mailer')->once()->andThrow(new RuntimeException('Warteschlange nicht erreichbar'));
+
+ $inhaber = operator('Owner');
+
+ expect(fn () => Livewire::actingAs($inhaber, 'operator')->test(Settings::class)
+ ->set('staffName', 'Theo')->set('staffEmail', 'theo@ops.test')->set('staffRole', 'Support')
+ ->call('inviteStaff'))->toThrow(RuntimeException::class);
+
+ expect(Operator::query()->where('email', 'theo@ops.test')->exists())->toBeFalse(
+ 'Das Konto blieb stehen, obwohl nie eine Einladung verschickt wurde — die Adresse wäre für immer blockiert.',
+ )->and(DB::table('operator_password_reset_tokens')->where('email', 'theo@ops.test')->exists())->toBeFalse();
+});