diff --git a/app/Livewire/Concerns/ConfirmsPassword.php b/app/Livewire/Concerns/ConfirmsPassword.php
new file mode 100644
index 0000000..fc31c06
--- /dev/null
+++ b/app/Livewire/Concerns/ConfirmsPassword.php
@@ -0,0 +1,94 @@
+ 0 && (time() - $at) < $this->confirmationWindow();
+ }
+
+ public function confirmPassword(): void
+ {
+ $key = 'confirm-password:'.auth()->id().'|'.request()->ip();
+
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ $this->addError('confirmablePassword', __('auth.throttle', [
+ 'seconds' => RateLimiter::availableIn($key),
+ ]));
+
+ return;
+ }
+
+ if (! Hash::check($this->confirmablePassword, auth()->user()->password)) {
+ RateLimiter::hit($key, 60);
+ $this->addError('confirmablePassword', __('admin_settings.password_wrong'));
+ $this->reset('confirmablePassword');
+
+ return;
+ }
+
+ RateLimiter::clear($key);
+ $this->reset('confirmablePassword');
+
+ // Regenerated first: a confirmation raises what this session can do, and
+ // a session id that was already known to someone else must not be the
+ // one that gets the extra authority.
+ session()->regenerate();
+ session(['auth.password_confirmed_at' => time()]);
+ }
+
+ /** Give up the confirmation early — leaving a page should not keep it open. */
+ public function forgetPasswordConfirmation(): void
+ {
+ session()->forget('auth.password_confirmed_at');
+ }
+
+ /**
+ * A value shown only in outline: the last four characters, and nothing else.
+ *
+ * Enough to tell two keys apart, useless to anyone reading over a shoulder
+ * or scrolling back through a screen recording.
+ */
+ protected function outline(?string $value, int $keep = 4): ?string
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return Str::length($value) <= $keep
+ ? str_repeat('•', Str::length($value))
+ : str_repeat('•', 8).Str::substr($value, -$keep);
+ }
+}
diff --git a/app/Livewire/Settings.php b/app/Livewire/Settings.php
index e131dcc..01e0a02 100644
--- a/app/Livewire/Settings.php
+++ b/app/Livewire/Settings.php
@@ -12,6 +12,79 @@ use Livewire\WithFileUploads;
class Settings extends Component
{
use \App\Livewire\Concerns\ChangesOwnPassword;
+ use \App\Livewire\Concerns\ConfirmsPassword;
+
+ /** Shown once, right after setting two-factor up. */
+ public ?array $recoveryCodes = null;
+
+ public string $twoFactorCode = '';
+
+ /**
+ * Start two-factor setup: generate the secret, then show the QR code.
+ *
+ * Not confirmed yet — Fortify keeps `two_factor_confirmed_at` null until a
+ * code from the app has been accepted, so someone who scans nothing and
+ * closes the tab is not left locked out of their own account.
+ */
+ public function enableTwoFactor(): void
+ {
+ $this->requireConfirmedPassword();
+
+ app(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
+ }
+
+ /** Accept a code from the app, which is what actually turns it on. */
+ public function confirmTwoFactor(): void
+ {
+ $this->requireConfirmedPassword();
+
+ try {
+ app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
+ auth()->user(),
+ $this->twoFactorCode,
+ );
+ } catch (\Illuminate\Validation\ValidationException $e) {
+ $this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_code_wrong'));
+
+ return;
+ }
+
+ $this->twoFactorCode = '';
+ // Shown once, here, because this is the only moment they exist in a
+ // form anyone will read. They stay retrievable afterwards, but nobody
+ // writes down what they were not shown.
+ $this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true);
+ $this->dispatch('notify', message: __('settings.twofa_on'));
+ }
+
+ public function regenerateRecoveryCodes(): void
+ {
+ $this->requireConfirmedPassword();
+
+ app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
+ $this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
+ }
+
+ public function disableTwoFactor(): void
+ {
+ $this->requireConfirmedPassword();
+
+ app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
+ $this->recoveryCodes = null;
+ $this->dispatch('notify', message: __('settings.twofa_off'));
+ }
+
+ /**
+ * Every two-factor action goes through this.
+ *
+ * Server-side, on each call, and not merely by hiding the buttons: a
+ * Livewire action is reachable by anyone who can post to /livewire/update,
+ * and "the form was not on screen" has never stopped anybody.
+ */
+ private function requireConfirmedPassword(): void
+ {
+ abort_unless($this->passwordRecentlyConfirmed(), 403);
+ }
use ResolvesCustomer, WithFileUploads;
@@ -152,7 +225,18 @@ class Settings extends Component
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
+ $user = auth()->user();
+
return view('livewire.settings', [
+ // Never the secret itself — only whether it exists, and the SVG
+ // Fortify renders from it. The secret in a Livewire property would
+ // travel to the browser and back in the component snapshot.
+ 'twoFactorPending' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null,
+ 'twoFactorOn' => $user->two_factor_confirmed_at !== null,
+ 'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null
+ ? $user->twoFactorQrCodeSvg()
+ : null,
+ 'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
'customer' => $c,
'instance' => $instance,
'branding' => $c?->brandingResolved(),
diff --git a/lang/de/settings.php b/lang/de/settings.php
index 59d74e9..cbcdfd7 100644
--- a/lang/de/settings.php
+++ b/lang/de/settings.php
@@ -53,4 +53,23 @@ return [
'close_mismatch' => 'Bitte das Bestätigungswort korrekt eingeben.',
'close_blocked_title' => 'Noch nicht möglich',
'close_blocked' => 'Bitte kündigen Sie zuerst Ihr aktives Paket, bevor Sie das Konto schließen.',
+
+ 'twofa_title' => 'Zwei-Faktor-Anmeldung',
+ 'twofa_sub' => 'Zusätzlich zum Passwort ein Code aus einer App. Schützt das Konto auch dann, wenn das Passwort irgendwo abhandenkommt.',
+ 'twofa_state_on' => 'Aktiv',
+ 'twofa_state_off' => 'Nicht eingerichtet',
+ 'twofa_confirm_first' => 'Bitte zuerst Ihr Passwort bestätigen — eine angemeldete Sitzung allein genügt hier nicht.',
+ 'twofa_confirm_button' => 'Bestätigen',
+ 'twofa_enable' => 'Einrichten',
+ 'twofa_scan' => 'Scannen Sie den Code mit Ihrer Authenticator-App und geben Sie danach die angezeigte Ziffernfolge ein.',
+ 'twofa_code' => 'Code aus der App',
+ 'twofa_activate' => 'Aktivieren',
+ 'twofa_code_wrong' => 'Dieser Code stimmt nicht.',
+ 'twofa_on' => 'Zwei-Faktor-Anmeldung ist aktiv.',
+ 'twofa_off' => 'Zwei-Faktor-Anmeldung wurde entfernt.',
+ 'twofa_off_confirm' => 'Zwei-Faktor-Anmeldung wirklich entfernen? Das Konto ist danach nur noch durch das Passwort geschützt.',
+ 'twofa_disable' => 'Entfernen',
+ 'twofa_new_codes' => 'Neue Wiederherstellungscodes',
+ 'twofa_codes_title' => 'Wiederherstellungscodes',
+ 'twofa_codes_hint' => 'Jetzt notieren und sicher verwahren. Sie sind der Weg zurück, wenn das Gerät verloren geht — jeder Code gilt einmal.',
];
diff --git a/lang/en/settings.php b/lang/en/settings.php
index 8d16e99..2782d09 100644
--- a/lang/en/settings.php
+++ b/lang/en/settings.php
@@ -53,4 +53,23 @@ return [
'close_mismatch' => 'Please type the confirmation word correctly.',
'close_blocked_title' => 'Not possible yet',
'close_blocked' => 'Please cancel your active package before closing the account.',
+
+ 'twofa_title' => 'Two-factor sign-in',
+ 'twofa_sub' => 'A code from an app in addition to the password. Protects the account even if the password gets out.',
+ 'twofa_state_on' => 'Active',
+ 'twofa_state_off' => 'Not set up',
+ 'twofa_confirm_first' => 'Confirm your password first — a signed-in session alone is not enough here.',
+ 'twofa_confirm_button' => 'Confirm',
+ 'twofa_enable' => 'Set up',
+ 'twofa_scan' => 'Scan the code with your authenticator app, then enter the digits it shows.',
+ 'twofa_code' => 'Code from the app',
+ 'twofa_activate' => 'Activate',
+ 'twofa_code_wrong' => 'That code is not right.',
+ 'twofa_on' => 'Two-factor sign-in is active.',
+ 'twofa_off' => 'Two-factor sign-in has been removed.',
+ 'twofa_off_confirm' => 'Really remove two-factor sign-in? The account will then be protected by its password alone.',
+ 'twofa_disable' => 'Remove',
+ 'twofa_new_codes' => 'New recovery codes',
+ 'twofa_codes_title' => 'Recovery codes',
+ 'twofa_codes_hint' => 'Write these down now and keep them safe. They are the way back in if the device is lost — each works once.'
];
diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php
index a24a26f..907f99d 100644
--- a/resources/views/livewire/admin/settings.blade.php
+++ b/resources/views/livewire/admin/settings.blade.php
@@ -64,7 +64,7 @@
{{ __('admin_settings.update_running') }}
@elseif ($update['available'])
-
{{ __('admin_settings.console_ip_hint') }}
@@ -215,7 +215,7 @@{{ __('settings.twofa_sub') }}
+{{ __('settings.twofa_codes_title') }}
+{{ __('settings.twofa_codes_hint') }}
+