diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index b190b49..563985d 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Http\Middleware\RestrictConsoleNetwork; use App\Livewire\Concerns\ChangesOwnPassword; use App\Livewire\Concerns\ResolvesOperator; +use App\Mail\OperatorInvitationMail; use App\Models\Customer; use App\Models\Operator; use App\Models\VpnPeer; @@ -15,7 +16,8 @@ use App\Support\PasswordPolicy; use App\Support\Settings as AppSettings; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; @@ -111,11 +113,6 @@ class Settings extends Component public string $staffRole = 'Support'; - /** Shown once after inviting, since email delivery is still mocked. */ - public ?string $invitedEmail = null; - - public ?string $invitedPassword = null; - public function savePasswordPolicy(): void { $this->authorize('settings.manage'); @@ -240,19 +237,42 @@ class Settings extends Component return; } - // Email/password-setup delivery is still mocked, so generate a temporary - // password and surface it once to the Owner to share securely — the - // account is usable immediately (a proper invite link follows with mail). - $temp = Str::password(14); + // 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' => Hash::make($temp), + 'password' => Str::random(64), ]); $newStaff->assignRole($data['staffRole']); - $this->invitedEmail = $data['staffEmail']; - $this->invitedPassword = $temp; + // 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); + $this->reset('staffName', 'staffEmail'); $this->staffRole = 'Support'; $this->dispatch('notify', message: __('admin_settings.staff_invited')); @@ -645,6 +665,9 @@ class Settings extends Component 'roles' => Operator::operatorRoles(), 'canManageStaff' => $operator?->can('staff.manage') ?? false, 'isOwner' => $operator?->hasRole('Owner') ?? false, + // Damit der Hinweis unter dem Einladen-Formular dieselbe Frist + // nennt wie die Mail und der Broker — eine Quelle, drei Leser. + 'inviteHours' => OperatorInvitationMail::validHours(), // Only the tabs this operator can actually open. A tab that raises // a 403 is worse than one that is not there. 'tabs' => array_values(array_filter( diff --git a/app/Livewire/Auth/OperatorSetPassword.php b/app/Livewire/Auth/OperatorSetPassword.php new file mode 100644 index 0000000..bc61f4c --- /dev/null +++ b/app/Livewire/Auth/OperatorSetPassword.php @@ -0,0 +1,115 @@ + + */ + protected function rules(): array + { + return ['password' => [...PasswordPolicy::rules(), 'confirmed']]; + } + + public function mount(string $token): void + { + $this->token = $token; + // Aus der Adresszeile vorbelegt, aber als Feld sichtbar: eine Mail, die + // unterwegs umgebrochen wurde, lässt sich so noch von Hand retten. + $this->email = (string) request()->query('email', ''); + } + + /** + * NICHT reset(): Livewire\Component::reset() leert Eigenschaften, und eine + * Komponente, die sie neu deklariert, lädt gar nicht erst. + */ + public function save(): void + { + $this->validate(); + + $status = Password::broker('operators')->reset( + [ + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'token' => $this->token, + ], + function (Operator $operator, string $password) { + $operator->forceFill([ + 'password' => Hash::make($password), + // Jede bestehende „Angemeldet bleiben"-Sitzung stirbt mit. + // Beim ersten Setzen ist keine da; beim zweiten Weg über + // diese Seite — einer erneuten Einladung — sehr wohl. + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($operator)); + }, + ); + + 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. + $this->addError('email', __('auth.console_invite_invalid')); + + return; + } + + $this->done = true; + } + + public function render() + { + return view('livewire.auth.operator-set-password'); + } +} diff --git a/app/Mail/OperatorInvitationMail.php b/app/Mail/OperatorInvitationMail.php new file mode 100644 index 0000000..a67691f --- /dev/null +++ b/app/Mail/OperatorInvitationMail.php @@ -0,0 +1,72 @@ +mailer(MailCatalogue::mailer('operator-invitation')); + } + + /** + * Wie lange der Link in dieser Mail gilt, in Stunden. + * + * Aus der Broker-Einstellung gelesen statt danebengeschrieben: die Zahl + * steht im Text der Mail, im Hinweis unter dem Einladen-Formular und in der + * Vorschau — drei Orte mit derselben Zahl sind zwei Orte, an denen sie + * falsch wird. Die Frist selbst ist in config/auth.php begründet. + */ + public static function validHours(): int + { + return (int) round(((int) config('auth.passwords.operators.expire', 60)) / 60); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::SYSTEM, __('operator_invitation.subject'), 'operator-invitation'); + } + + public function content(): Content + { + return new Content(view: 'mail.operator-invitation', with: [ + 'name' => $this->operator->name, + 'invitedBy' => $this->invitedBy, + 'url' => $this->url, + 'hours' => $this->hours, + ]); + } +} diff --git a/app/Models/Operator.php b/app/Models/Operator.php index 34a9563..964a04b 100644 --- a/app/Models/Operator.php +++ b/app/Models/Operator.php @@ -63,6 +63,29 @@ class Operator extends Authenticatable ]; } + /** + * Für Betreiber gibt es keinen Selbstbedienungs-Weg zum Passwort. + * + * Der Broker `operators` beherrscht `sendResetLink()`, und die Vorgabe aus + * `CanResetPassword` verschickt dann Laravels eigene Benachrichtigung — mit + * einem Link auf `password.reset`, die Seite des PORTALS, die gegen den + * KUNDEN-Broker einlöst. Ein Betreiber-Token landete damit auf der + * Kundenseite und liefe dort ins Leere: R21 an der unauffälligsten Stelle, + * an der er brechen kann, und zwar still. + * + * Deshalb laut statt still. Die Einladung geht über + * App\Livewire\Admin\Settings::inviteStaff(), die den Token selbst zieht + * und ihre eigene Mail verschickt; eine Seite „Passwort vergessen" für + * Betreiber gibt es (noch) nicht. Wer sie baut, überschreibt diese Methode + * mit einer Mail, die auf `admin.*` zeigt — und löscht diesen Kommentar. + */ + public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void + { + throw new \RuntimeException( + 'Betreiber haben keinen Selbstbedienungs-Weg zum Passwort: Laravels Vorgabe verlinkt auf die Portal-Seite password.reset (R21).' + ); + } + /** True when this operator holds a role that reaches the console. */ public function isOperator(): bool { diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php index 7792e86..a2357e3 100644 --- a/app/Services/Mail/MailCatalogue.php +++ b/app/Services/Mail/MailCatalogue.php @@ -24,6 +24,12 @@ final class MailCatalogue return [ 'verify-email' => ['label' => 'E-Mail bestätigen (Registrierung)', 'purpose' => MailPurpose::SYSTEM], 'reset-password' => ['label' => 'Passwort zurücksetzen', 'purpose' => MailPurpose::SYSTEM], + // Die Einladung in die Konsole. SYSTEM wie ihre beiden Nachbarn: + // eine Mail über den ZUGANG selbst, nicht über einen Kauf, einen + // Betriebsvorgang oder eine Supportantwort. Sie trägt kein + // Passwort, nur einen Link, an dem der Eingeladene sein eigenes + // vergibt — siehe App\Mail\OperatorInvitationMail. + 'operator-invitation' => ['label' => 'Einladung in die Konsole', 'purpose' => MailPurpose::SYSTEM], 'order-confirmation' => ['label' => 'Bestellbestätigung', 'purpose' => MailPurpose::BILLING], 'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING], 'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE], diff --git a/app/Services/Mail/MailLane.php b/app/Services/Mail/MailLane.php index 08c849d..e46f99e 100644 --- a/app/Services/Mail/MailLane.php +++ b/app/Services/Mail/MailLane.php @@ -11,6 +11,7 @@ use App\Mail\InvoiceMail; use App\Mail\MaintenanceAnnouncementMail; use App\Mail\MaintenanceCancelledMail; use App\Mail\NewDeviceSignInMail; +use App\Mail\OperatorInvitationMail; use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; @@ -41,7 +42,7 @@ final class MailLane public const CALM = 'mail-ruhig'; /** - * Die sieben, auf die gerade jemand wartet. + * Die acht, auf die gerade jemand wartet. * * Sie entstehen einzeln, weil ein einzelner Mensch geklickt hat, und * können gar keinen Schub bilden. Drosseln nützt dort nichts und kostet @@ -56,6 +57,11 @@ final class MailLane ContactRequestMail::class, OrderConfirmationMail::class, OperatorMessageMail::class, + // Die Einladung in die Konsole. Der Inhaber hat gerade geklickt und + // sagt dem Eingeladenen im selben Moment „schau in dein Postfach" — + // eine Einladung, die eine Viertelstunde in der ruhigen Spur liegt, + // ist ein Anruf. + OperatorInvitationMail::class, ]; /** @var array */ diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index f74951b..6d6fa67 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -12,6 +12,7 @@ use App\Mail\InvoiceMail; use App\Mail\MaintenanceAnnouncementMail; use App\Mail\MaintenanceCancelledMail; use App\Mail\NewDeviceSignInMail; +use App\Mail\OperatorInvitationMail; use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; @@ -21,6 +22,7 @@ use App\Models\Customer; use App\Models\Instance; use App\Models\Invoice; use App\Models\MaintenanceWindow; +use App\Models\Operator; use App\Models\Order; use App\Models\SecurityBlock; use App\Models\User; @@ -91,6 +93,16 @@ class MailPreviews return match ($key) { 'verify-email' => new VerifyEmailMail($user), 'reset-password' => new ResetPasswordMail($user, 'https://example.test/reset/beispiel-token', 60), + 'operator-invitation' => new OperatorInvitationMail( + $this->invitedOperator(), + 'Ines Inhaber', + 'https://example.test/admin/invitation/beispiel-token', + // Aus der Broker-Einstellung, nicht als Zahl daneben: eine + // Vorschau, die eine andere Frist nennt als die echte Mail, + // ist genau die Sorte Beleg, auf die sich niemand verlassen + // kann. + OperatorInvitationMail::validHours(), + ), 'order-confirmation' => new OrderConfirmationMail($this->order($customer), $customer->name), 'cloud-ready' => $this->cloudReady($customer), 'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer), @@ -146,6 +158,20 @@ class MailPreviews ->forceFill(['id' => 0, 'email_verified_at' => null]); } + /** + * Der eingeladene Betreiber. make(), nie create() — wie alles hier. + * + * Ein Betreiber und kein User: die Einladung geht in die Konsole, und die + * beiden Personengruppen teilen keine Identität (R21). Ein Beispiel, das + * die falsche Klasse benutzt, wäre eine Vorschau auf eine Mail, die es so + * nie gibt. + */ + private function invitedOperator(): Operator + { + return Operator::make(['name' => 'Tom Neu', 'email' => 'tom@clupilot.test']) + ->forceFill(['id' => 0]); + } + private function order(Customer $customer): Order { return Order::make([ diff --git a/config/auth.php b/config/auth.php index 594aa33..90b2fe5 100644 --- a/config/auth.php +++ b/config/auth.php @@ -1,5 +1,6 @@ [ 'driver' => 'eloquent', - 'model' => App\Models\Operator::class, + 'model' => Operator::class, ], ], @@ -114,10 +115,31 @@ return [ 'throttle' => 60, ], + // Die Betreiber, mit EIGENER Tabelle (R21). + // + // Hier stand `password_reset_tokens` — die Tabelle der Kunden. Ein + // Token ist nur ein Zeilenschlüssel: teilen sich beide Personengruppen + // die Tabelle, kann ein Token der einen auf der Seite der anderen + // eingelöst werden. Dass die Adressen sich heute nicht überschneiden + // (Admin\Settings weist eine Kundenadresse ab), ist eine Prüfung an + // einer Stelle, keine Struktur. + // + // `expire` ist hier die Frist des EINLADUNGSLINKS, nicht die einer + // Selbstbedienungs-Rücksetzung: den Weg gibt es für Betreiber (noch) + // nicht, dieser Broker stellt heute ausschliesslich Einladungen aus. + // 4320 Minuten sind 72 Stunden und decken ein Wochenende ab — am + // Freitagnachmittag verschickt, am Montagfrüh noch brauchbar. Kürzer, + // und jede Einladung vor einem freien Tag ist beim Öffnen schon tot; + // deutlich länger, und ein altes Postfach hält auf Dauer einen + // Schlüssel zur Betreiberkonsole, die die ganze Flotte verwaltet. + // + // 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. 'operators' => [ 'provider' => 'operators', - 'table' => 'password_reset_tokens', - 'expire' => 60, + 'table' => 'operator_password_reset_tokens', + 'expire' => 4320, 'throttle' => 60, ], ], diff --git a/database/migrations/2026_08_04_120000_betreiber_setzen_ihr_passwort_selbst.php b/database/migrations/2026_08_04_120000_betreiber_setzen_ihr_passwort_selbst.php new file mode 100644 index 0000000..e85ac26 --- /dev/null +++ b/database/migrations/2026_08_04_120000_betreiber_setzen_ihr_passwort_selbst.php @@ -0,0 +1,41 @@ +string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('operator_password_reset_tokens'); + } +}; diff --git a/lang/de/admin_settings.php b/lang/de/admin_settings.php index 07587e7..c545cf6 100644 --- a/lang/de/admin_settings.php +++ b/lang/de/admin_settings.php @@ -26,11 +26,11 @@ return [ 'staff_sub' => 'Mitarbeiter einladen und deren Rolle festlegen. Nur der Inhaber kann das Team verwalten.', 'role' => 'Rolle', 'invite' => 'Einladen', - 'invite_hint' => 'Der Zugang wird angelegt; die Einladungs-/Passwort-Zustellung folgt mit der E-Mail-Anbindung.', - 'staff_invited' => 'Mitarbeiter angelegt.', - 'temp_title' => 'Zugangsdaten (einmalig sichtbar)', - 'temp_hint' => 'Teilen Sie diese Daten sicher mit dem Mitarbeiter. Beim späteren E-Mail-Setup wird ein Einladungslink versendet.', - 'temp_password' => 'Temporäres Passwort', + // Kein Passwort mehr auf dem Bildschirm: der Eingeladene vergibt sein + // eigenes am Link aus der Mail. Die Stundenzahl kommt aus der + // 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.', 'col_person' => 'Person', 'col_actions' => 'Aktionen', diff --git a/lang/de/auth.php b/lang/de/auth.php index acb2404..c83619d 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -76,6 +76,17 @@ return [ 'reset_new' => 'Neues Passwort', 'reset_repeat' => 'Neues Passwort wiederholen', 'reset_save' => 'Passwort speichern', + // Die eingeladene Betreiberin vergibt ihr Passwort — eigene Seite der + // Konsole (R21), eigener Broker, eigene Tabelle. Sie leiht sich die Felder + // von der Rücksetzseite (reset_new, reset_repeat, reset_save), weil es + // dieselben drei Eingaben sind; die Überschriften sind eigene, weil hier + // niemand etwas zurücksetzt, sondern zum ersten Mal eines vergibt. + 'console_invite_title' => 'Passwort vergeben', + 'console_invite_subtitle' => 'Sie wurden in die CluPilot-Konsole eingeladen. Vergeben Sie hier Ihr Passwort — ausser Ihnen kennt es dann niemand.', + 'console_invite_done_title' => 'Passwort gesetzt', + 'console_invite_done_body' => 'Melden Sie sich jetzt damit an der Konsole an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.', + 'console_invite_invalid' => 'Dieser Link gilt nicht mehr. Einladungslinks laufen ab und lassen sich nur einmal verwenden — bitten Sie um eine neue Einladung.', + 'reset_done_title' => 'Passwort geändert', 'reset_done_body' => 'Alle anderen offenen Anmeldungen wurden beendet. Melden Sie sich jetzt mit dem neuen Passwort an — absichtlich nicht automatisch: der Link kam per E-Mail, und wer Ihr Postfach lesen kann, bekäme sonst gleich die Sitzung dazu.', diff --git a/lang/de/mail_pace.php b/lang/de/mail_pace.php index 6da230e..4614f34 100644 --- a/lang/de/mail_pace.php +++ b/lang/de/mail_pace.php @@ -53,6 +53,7 @@ return [ 'ContactRequestMail' => 'Kontaktanfrage von der Website', 'OrderConfirmationMail' => 'Bestellbestätigung', 'OperatorMessageMail' => 'Nachricht aus der Konsole', + 'OperatorInvitationMail' => 'Einladung in die Konsole', 'MaintenanceAnnouncementMail' => 'Wartungsfenster angekündigt', 'MaintenanceCancelledMail' => 'Wartungsfenster abgesagt', 'CloudSuspendedMail' => 'Cloud abgeschaltet', diff --git a/lang/de/operator_invitation.php b/lang/de/operator_invitation.php new file mode 100644 index 0000000..1aad793 --- /dev/null +++ b/lang/de/operator_invitation.php @@ -0,0 +1,16 @@ + 'Ihr Zugang zur CluPilot-Konsole', + 'preheader' => 'Vergeben Sie Ihr Passwort — der Link gilt :hours Stunden.', + 'heading' => 'Willkommen in der Konsole', + 'greeting' => 'Guten Tag :name,', + 'intro' => ':inviter hat Ihnen einen Zugang zur CluPilot-Konsole eingerichtet. Ein Passwort haben wir Ihnen nicht vergeben — das machen Sie selbst, über den Knopf unten. So kennt es ausser Ihnen niemand.', + 'action' => 'Passwort vergeben', + 'expiry' => 'Der Link gilt :hours Stunden und lässt sich nur einmal verwenden.', + 'fallback' => 'Falls der Knopf nicht funktioniert, kopieren Sie diese Adresse in Ihren Browser:', + 'network' => 'Die Konsole ist nur aus dem Verwaltungsnetz erreichbar. Wenn die Seite gar nicht antwortet, sind Sie nicht am falschen Link — dann fehlt Ihnen der Netzzugang. Fragen Sie :inviter danach.', + 'not_you' => 'Sie haben damit nichts zu tun? Dann ignorieren Sie diese Nachricht — ohne den Link passiert nichts. Wir fragen Sie nie per E-Mail nach Ihrem Passwort.', +]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index 2a77d7d..9a31bab 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -26,11 +26,8 @@ return [ 'staff_sub' => 'Invite staff and set their role. Only the Owner can manage the team.', 'role' => 'Role', 'invite' => 'Invite', - 'invite_hint' => 'The account is created; invitation/password delivery follows with the email integration.', - 'staff_invited' => 'Staff member created.', - 'temp_title' => 'Credentials (shown once)', - 'temp_hint' => 'Share these securely with the staff member. A proper invitation link follows with the email integration.', - 'temp_password' => 'Temporary password', + '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.', 'col_person' => 'Person', 'col_actions' => 'Actions', diff --git a/lang/en/auth.php b/lang/en/auth.php index f234370..551b3ef 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -76,6 +76,12 @@ return [ 'reset_new' => 'New password', 'reset_repeat' => 'Repeat the new password', 'reset_save' => 'Save the password', + 'console_invite_title' => 'Set your password', + 'console_invite_subtitle' => 'You have been invited to the CluPilot console. Set your password here — nobody but you will know it.', + 'console_invite_done_title' => 'Password set', + 'console_invite_done_body' => 'Sign in to the console with it now — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.', + 'console_invite_invalid' => 'This link is no longer valid. Invitation links expire and can only be used once — ask for a new invitation.', + 'reset_done_title' => 'Password changed', 'reset_done_body' => 'Every other open session has been ended. Sign in now with the new password — deliberately not automatically: the link arrived by email, and whoever can read your mailbox would otherwise get the session with it.', diff --git a/lang/en/mail_pace.php b/lang/en/mail_pace.php index 079cabd..3f3eec8 100644 --- a/lang/en/mail_pace.php +++ b/lang/en/mail_pace.php @@ -47,6 +47,7 @@ return [ 'ContactRequestMail' => 'Contact request from the website', 'OrderConfirmationMail' => 'Order confirmation', 'OperatorMessageMail' => 'Message from the console', + 'OperatorInvitationMail' => 'Invitation to the console', 'MaintenanceAnnouncementMail' => 'Maintenance window announced', 'MaintenanceCancelledMail' => 'Maintenance window cancelled', 'CloudSuspendedMail' => 'Cloud suspended', diff --git a/lang/en/operator_invitation.php b/lang/en/operator_invitation.php new file mode 100644 index 0000000..e4c7d30 --- /dev/null +++ b/lang/en/operator_invitation.php @@ -0,0 +1,16 @@ + 'Your access to the CluPilot console', + 'preheader' => 'Set your password — the link is valid for :hours hours.', + 'heading' => 'Welcome to the console', + 'greeting' => 'Hello :name,', + 'intro' => ':inviter has set up console access for you at CluPilot. We have not issued you a password — you set your own with the button below, so that nobody but you knows it.', + 'action' => 'Set a password', + 'expiry' => 'The link is valid for :hours hours and can be used once.', + 'fallback' => 'If the button does not work, copy this address into your browser:', + 'network' => 'The console can only be reached from the management network. If the page does not answer at all, the link is not the problem — you are missing network access. Ask :inviter about it.', + 'not_you' => 'Nothing to do with you? Then ignore this message — without the link nothing happens. We never ask for your password by email.', +]; diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 8df61f1..a16b11a 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -569,18 +569,14 @@ {{ __('admin_settings.invite') }} -

{{ __('admin_settings.invite_hint') }}

- - @if ($invitedPassword) -
-

{{ __('admin_settings.temp_title') }}

-

{{ __('admin_settings.temp_hint') }}

-
-
{{ __('admin_settings.email') }}:
{{ $invitedEmail }}
-
{{ __('admin_settings.temp_password') }}:
{{ $invitedPassword }}
-
-
- @endif + {{-- Hier stand ein Kasten, der dem Inhaber das erzeugte Passwort + des neuen Mitarbeiters einmal anzeigte, damit er es „sicher + weitergibt". Ein Passwort, das zwei Menschen kennen, gehört + keinem von beiden — erst recht nicht auf einer Konsole, die + die ganze Flotte verwaltet. Es gibt jetzt keines mehr + anzuzeigen: der Eingeladene vergibt sein eigenes über den + Link aus der Einladungsmail. --}} +

{{ __('admin_settings.invite_hint', ['hours' => $inviteHours]) }}

{{-- Staff list --}}
diff --git a/resources/views/livewire/auth/operator-set-password.blade.php b/resources/views/livewire/auth/operator-set-password.blade.php new file mode 100644 index 0000000..b996e85 --- /dev/null +++ b/resources/views/livewire/auth/operator-set-password.blade.php @@ -0,0 +1,29 @@ +
+
+ + + @if ($done) +
+ +
+

{{ __('auth.console_invite_done_title') }}

+ {{-- Absichtlich nicht automatisch angemeldet: der Link kam per + E-Mail, und wer dieses Postfach lesen kann, bekäme sonst die + Sitzung in der Konsole gleich dazu. --}} +

{{ __('auth.console_invite_done_body') }}

+ + {{ __('auth.sign_in') }} + @else +

{{ __('auth.console_invite_title') }}

+

{{ __('auth.console_invite_subtitle') }}

+ +
+ + + + {{ __('auth.reset_save') }} + + @endif +
+
diff --git a/resources/views/mail/operator-invitation.blade.php b/resources/views/mail/operator-invitation.blade.php new file mode 100644 index 0000000..e67c562 --- /dev/null +++ b/resources/views/mail/operator-invitation.blade.php @@ -0,0 +1,54 @@ + + + +

{{ __('operator_invitation.intro', ['inviter' => $invitedBy]) }}

+ + + + + +
+ {{ __('operator_invitation.action') }} +
+

{!! __('operator_invitation.expiry', ['hours' => ''.$hours.'']) !!}

+ + +{{-- Der Link auch als Text, aus demselben Grund wie bei der Bestätigungs- und + der Rücksetzmail: Mail-Gateways in Firmen schreiben Knopf-Links um, und + manche holen das Ziel vorher ab, um es zu prüfen — bei einem Link, der sich + nur einmal einlösen lässt, ist er damit verbraucht, bevor ihn jemand + angeklickt hat. --}} + + + +
+

{{ __('operator_invitation.fallback') }}

+

{{ $url }}

+
+ + +{{-- Die Konsole ist netzbeschränkt (RestrictConsoleNetwork): wer nicht im + Verwaltungsnetz sitzt, bekommt auf diesen Link ein 404 und nicht etwa eine + Fehlermeldung, die den Grund nennt. Ohne diesen Satz sähe das wie ein + kaputter Link aus, und der Eingeladene würde ihn wieder und wieder + anklicken, statt den zu fragen, der ihn geschickt hat. --}} + +

{{ __('operator_invitation.network', ['inviter' => $invitedBy]) }}

+ + +{{-- Wer das nicht erwartet hat, soll NICHTS tun. Eine Mail, die einen Zugang + zu einer Verwaltungskonsole anbietet, ist der Bauplan jedes Phishing- + Versuchs — deshalb steht hier dieselbe Regel wie auf der Rücksetzmail. --}} + + + +
+

{{ __('operator_invitation.not_you') }}

+
+ + +
diff --git a/routes/admin-guest.php b/routes/admin-guest.php index eba81ba..14e08dd 100644 --- a/routes/admin-guest.php +++ b/routes/admin-guest.php @@ -1,6 +1,7 @@ name('login'); Route::get('/two-factor', OperatorTwoFactorChallenge::class)->name('two-factor'); + +/* +| Die Einladung: hier vergibt ein neu angelegter Betreiber sein Passwort. +| +| In der GAST-Gruppe der Konsole und nicht in routes/admin.php — die liegt +| hinter `auth:operator,web`, und wer diesen Link öffnet, hat noch gar keinen +| Zugang. Es bleibt trotzdem eine Route der Konsole und keine des Portals: sie +| heisst `admin.invitation`, sie liegt unter demselben Präfix beziehungsweise +| Hostnamen wie der Rest der Konsole, und sie durchläuft damit denselben +| `web`-Stapel — also RestrictAdminHost (auf welchem Namen die Konsole +| antwortet) UND RestrictConsoleNetwork (wer sie überhaupt erreichen darf). +| Ein Einladungslink führt deshalb bewusst in ein Netz, das der Eingeladene +| erst haben muss; die Mail sagt das ausdrücklich, damit ein 404 nicht wie ein +| kaputter Link aussieht. +*/ +Route::get('/invitation/{token}', OperatorSetPassword::class)->name('invitation'); diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index 34e9433..7a43175 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -4,7 +4,7 @@ use App\Livewire\Admin\Settings; use App\Models\Customer; use App\Models\Operator; use App\Models\User; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; use Livewire\Livewire; // operator() helper defined in RbacTest. @@ -29,7 +29,14 @@ it('will not save an account email that collides with an orphaned portal login', ->assertHasErrors(['email']); }); +// Was beim Einladen mit dem PASSWORT passiert — es entsteht keines, das +// jemand weitergeben könnte, und der Link geht per Mail hinaus — steht in +// tests/Feature/Auth/OperatorInvitationTest.php. Hier bleibt nur, was diese +// Seite selbst zusichert: wer einladen darf, mit welcher Rolle, und gegen +// welche Adressen sie sich wehrt. it('lets an Owner invite staff with a role', function () { + Mail::fake(); + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') ->call('inviteStaff')->assertHasNoErrors(); @@ -38,17 +45,6 @@ it('lets an Owner invite staff with a role', function () { expect($sam)->not->toBeNull()->and($sam->hasRole('Support'))->toBeTrue(); }); -it('surfaces a usable temporary password for the invited staff', function () { - $c = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) - ->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support') - ->call('inviteStaff'); - - $temp = $c->get('invitedPassword'); - expect($temp)->not->toBeNull(); - $ivy = Operator::query()->where('email', 'ivy@ops.test')->first(); - expect(Hash::check($temp, $ivy->password))->toBeTrue(); -}); - it('forbids a non-Owner from managing staff', function () { Livewire::actingAs(operator('Admin'), 'operator')->test(Settings::class) ->set('staffName', 'X')->set('staffEmail', 'x@ops.test')->set('staffRole', 'Support') diff --git a/tests/Feature/Auth/OperatorInvitationTest.php b/tests/Feature/Auth/OperatorInvitationTest.php new file mode 100644 index 0000000..6a413af --- /dev/null +++ b/tests/Feature/Auth/OperatorInvitationTest.php @@ -0,0 +1,384 @@ +test(Settings::class) + ->set('staffName', 'Sam')->set('staffEmail', $email)->set('staffRole', $role) + ->call('inviteStaff')->assertHasNoErrors(); + + auth('operator')->logout(); + + return Operator::query()->where('email', $email)->firstOrFail(); +} + +// ---- Was das Konto beim Anlegen ist: noch niemandes ---- + +it('legt ein Konto an, in das sich niemand anmelden kann', function () { + Mail::fake(); + + $sam = invite(); + + // Die halbe Zusicherung, und sie muss halten: es gibt kein Passwort, das + // dieses Konto öffnet — auch keines, das jemand raten würde, weil er den + // Code gelesen hat. + foreach (['', 'password', 'Sam', 'sam@ops.test', 'clupilot'] as $versuch) { + expect(Hash::check($versuch, $sam->password))->toBeFalse("[{$versuch}] öffnet das Konto"); + } + + Livewire::test(OperatorLogin::class) + ->set('email', 'sam@ops.test')->set('password', 'password') + ->call('authenticate')->assertHasErrors('email'); + + expect(auth('operator')->check())->toBeFalse(); +}); + +it('trägt das Passwort des neuen Kontos in keiner Eigenschaft der Seite', 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. + Mail::fake(); + + $seite = Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) + ->set('staffName', 'Ivy')->set('staffEmail', 'ivy@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff'); + + $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; + } + + expect(Hash::check($wert, $ivy->password))->toBeFalse( + "Die Eigenschaft {$eigenschaft->getName()} ist das Passwort des neuen Kontos.", + ); + } +}); + +// ---- Was hinausgeht: ein Link, kein Passwort ---- + +it('schickt eine Einladung mit dem Link und dem Namen des Einladenden', function () { + Mail::fake(); + + $inhaber = operator('Owner'); + + Livewire::actingAs($inhaber, 'operator')->test(Settings::class) + ->set('staffName', 'Sam')->set('staffEmail', 'sam@ops.test')->set('staffRole', 'Support') + ->call('inviteStaff')->assertHasNoErrors(); + + Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($inhaber) { + $html = $mail->render(); + + return $mail->hasTo('sam@ops.test') + // Der Link zeigt auf die Seite der KONSOLE, nicht auf die des + // Portals — das ist R21 im Ergebnis statt in der Absicht. + && str_contains($mail->url, '/admin/invitation/') + && str_contains($html, $mail->url) + // Wer eingeladen hat, steht darin: eine Mail, die einem Menschen + // einen Konsolenzugang anbietet, ist sonst von einem + // Phishing-Versuch nicht zu unterscheiden. + && str_contains($html, $inhaber->name); + }); +}); + +it('trägt kein Passwort in der Mail', function () { + Mail::fake(); + + $sam = invite(); + + Mail::assertQueued(OperatorInvitationMail::class, function (OperatorInvitationMail $mail) use ($sam) { + // Jedes Wort ab acht Zeichen aus der fertigen Mail gegen den Hash des + // Kontos gehalten. Fiele irgendwo ein Passwort hinein — im Fliesstext, + // in einem Kasten, im Betreff — schlüge genau eines davon an. + preg_match_all('/[^\s<>"\']{8,}/', strip_tags($mail->render()), $treffer); + + foreach ($treffer[0] as $wort) { + expect(Hash::check($wort, $sam->password))->toBeFalse("[{$wort}] ist das Passwort des Kontos"); + } + + return true; + }); +}); + +// ---- R21: eigene Tabelle, eigener Broker, eigene Seite ---- + +it('legt den Token in die Tabelle der Betreiber und nicht in die der Kunden', function () { + Mail::fake(); + + invite(); + + expect(DB::table('operator_password_reset_tokens')->count())->toBe(1) + // Ein Token ist nur ein Zeilenschlüssel. Läge er in der Kundentabelle, + // wäre die Trennung der beiden Personengruppen genau hier aufgehoben. + ->and(DB::table('password_reset_tokens')->count())->toBe(0); +}); + +it('löst einen Kunden-Token nicht auf der Konsolenseite ein', function () { + // Die Adressen dürfen sich heute nicht überschneiden — Admin\Settings weist + // eine Kundenadresse ab. Das ist eine Prüfung an EINER Stelle, keine + // Struktur, also wird sie hier absichtlich umgangen: beide Datensätze + // entstehen direkt. + $betreiber = Operator::factory()->role('Support')->create([ + 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-betreibers', + ]); + $kunde = User::factory()->create([ + 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden', + ]); + + $kundenToken = Password::broker()->createToken($kunde); + + Livewire::test(OperatorSetPassword::class, ['token' => $kundenToken]) + ->set('email', 'doppelt@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertHasErrors('email') + ->assertSet('done', false); + + expect(Hash::check('passwort-des-betreibers', $betreiber->fresh()->password))->toBeTrue() + ->and(Hash::check('ein-langes-neues-passwort', $betreiber->fresh()->password))->toBeFalse(); +}); + +it('löst einen Betreiber-Token nicht auf der Portalseite ein', function () { + // Die Gegenrichtung. Sie ist die gefährlichere: ein Token aus der Konsole, + // eingelöst im Portal, setzte das Passwort eines KUNDEN. + $betreiber = Operator::factory()->role('Support')->create(['email' => 'doppelt@ops.test']); + $kunde = User::factory()->create([ + 'email' => 'doppelt@ops.test', 'password' => 'passwort-des-kunden', + ]); + + $betreiberToken = Password::broker('operators')->createToken($betreiber); + + Livewire::test(ResetPassword::class, ['token' => $betreiberToken]) + ->set('email', 'doppelt@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertHasErrors('email') + ->assertSet('done', false); + + expect(Hash::check('passwort-des-kunden', $kunde->fresh()->password))->toBeTrue(); +}); + +it('schickt einem Betreiber nicht Laravels eigene Rücksetzmail', function () { + // Die Vorgabe aus CanResetPassword verlinkt auf `password.reset` — die + // Seite des PORTALS. Ein Betreiber-Token landete damit auf der Kundenseite. + // Konkrete Klasse, nicht Throwable: eine Zusicherung auf eine Schnittstelle + // beweist nichts. + $betreiber = Operator::factory()->role('Support')->create(); + + expect(fn () => $betreiber->sendPasswordResetNotification('irgendein-token')) + ->toThrow(RuntimeException::class); +}); + +// ---- Der Link: einlösen, verbrauchen, ablaufen ---- + +it('setzt das Passwort und lässt den Eingeladenen sich damit anmelden', function () { + Mail::fake(); + + $sam = invite(); + $token = Password::broker('operators')->createToken($sam); + + Livewire::test(OperatorSetPassword::class, ['token' => $token]) + ->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(); + + Livewire::test(OperatorLogin::class) + ->set('email', 'sam@ops.test')->set('password', 'ein-langes-neues-passwort') + ->call('authenticate')->assertHasNoErrors(); + + expect(auth('operator')->id())->toBe($sam->id); +}); + +it('meldet niemanden an, nur weil er den Link geöffnet hat', function () { + // Der Link kam per E-Mail. Wer dieses Postfach lesen kann, bekäme sonst die + // Sitzung in der Konsole gleich dazu. + Mail::fake(); + + $sam = invite(); + + Livewire::test(OperatorSetPassword::class, ['token' => Password::broker('operators')->createToken($sam)]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save'); + + expect(auth('operator')->check())->toBeFalse(); +}); + +it('verbraucht den Link, damit eine weitergeleitete Mail ihn nicht zweimal öffnet', function () { + Mail::fake(); + + $sam = invite(); + $token = Password::broker('operators')->createToken($sam); + + $einlösen = fn () => Livewire::test(OperatorSetPassword::class, ['token' => $token]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save'); + + $einlösen()->assertSet('done', true); + $einlösen()->assertSet('done', false)->assertHasErrors('email'); +}); + +it('lässt den Link ablaufen, statt ihn in einem alten Postfach liegen zu lassen', function () { + // Ein Einladungslink, der ein Jahr später noch gilt, ist ein Schlüssel zur + // Betreiberkonsole in einem Postfach, an das niemand mehr denkt. + Mail::fake(); + + $sam = invite(); + $token = Password::broker('operators')->createToken($sam); + + $stunden = OperatorInvitationMail::validHours(); + + // Eine Stunde vor Ablauf: gilt noch. + $this->travel($stunden - 1)->hours(); + + Livewire::test(OperatorSetPassword::class, ['token' => $token]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertSet('done', true); + + // Und derselbe Vorgang jenseits der Frist: ein frischer Token, eine Stunde + // nach Ablauf. + $frisch = Password::broker('operators')->createToken($sam); + $this->travel($stunden + 1)->hours(); + + Livewire::test(OperatorSetPassword::class, ['token' => $frisch]) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-noch-viel-neueres-passwort') + ->set('password_confirmation', 'ein-noch-viel-neueres-passwort') + ->call('save') + ->assertSet('done', false) + ->assertHasErrors('email'); + + expect(Hash::check('ein-noch-viel-neueres-passwort', $sam->fresh()->password))->toBeFalse(); +}); + +it('hält die Frist in Tagen, nicht in Monaten', function () { + // Der Test darüber prüft den MECHANISMUS: was auch immer eingestellt ist, + // läuft ab. Er würde eine Frist von einem Jahr klaglos mittragen, weil er + // die Zahl aus derselben Einstellung liest. Die Grössenordnung ist aber + // keine Geschmacksfrage: nach oben ist ein Link, der Wochen später noch + // gilt, ein Schlüssel zur Betreiberkonsole in einem Postfach, an das + // niemand mehr denkt; nach unten wird eine Einladung, die vor dem + // Wochenende abläuft, nie geöffnet. Die Zahl dazwischen ist in + // config/auth.php begründet. + expect(OperatorInvitationMail::validHours())->toBeGreaterThanOrEqual(24) + ->and(OperatorInvitationMail::validHours())->toBeLessThanOrEqual(7 * 24); +}); + +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. + Mail::fake(); + + invite(); + + Livewire::test(OperatorSetPassword::class, ['token' => 'gibt-es-nicht']) + ->set('email', 'sam@ops.test') + ->set('password', 'ein-langes-neues-passwort') + ->set('password_confirmation', 'ein-langes-neues-passwort') + ->call('save') + ->assertHasErrors('email') + ->assertSee(__('auth.console_invite_invalid')); +}); + +// ---- Wo der Link hinführt: in die Konsole, hinter ihre Schranken ---- + +it('legt die Einladungsseite hinter dieselbe Netzbeschränkung wie den Rest der Konsole', function () { + // Der Link führt in ein Netz, das der Eingeladene erreichen können muss — + // und ein Fremder eben nicht. 404, nie 403: dieselbe Haltung wie überall + // sonst in der Konsole. + config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']); + AppSettings::set('console.network_restricted', true); + AppSettings::set('console.allowed_ips', []); + + $ziel = route('admin.invitation', ['token' => 'beispiel-token', 'email' => 'sam@ops.test']); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->get($ziel)->assertNotFound(); + $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.5'])->get($ziel)->assertOk(); +}); + +it('führt die Einladung über eine Route der Konsole, nicht über eine des Portals', function () { + // Nicht der Pfad, sondern der NAME: er entscheidet in RestrictAdminHost, + // ob eine Route als Konsolenroute gilt. + $route = app('router')->getRoutes()->getByName('admin.invitation'); + + expect($route)->not->toBeNull() + ->and($route->getName())->toStartWith('admin.') + // Im Gast-Teil: wer den Link öffnet, hat noch keinen Zugang, und + // routes/admin.php liegt hinter `auth:operator,web`. + ->and($route->gatherMiddleware())->toContain('guest:operator') + ->and($route->gatherMiddleware())->not->toContain('auth:operator,web'); +}); + +// ---- Was die Seite nicht mehr anbietet ---- + +it('zeigt auf der Team-Seite keine Zugangsdaten mehr an', function () { + Mail::fake(); + + $html = 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(); + + // Die drei Zeilen, die den Kasten beschriftet haben, gibt es nicht mehr — + // in keiner der beiden Sprachen. + 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"); + } + + expect($html)->toContain(e(__('admin_settings.invite_hint', ['hours' => OperatorInvitationMail::validHours()]))); +});