feat(auth): SMTP-aware forgot-password — 15-min e-mail link + 2FA fallback; recovery note out of the public screen
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
cb3360b235
commit
d44f6930bb
18
README.md
18
README.md
|
|
@ -78,6 +78,24 @@ docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:ins
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Account recovery / Wiederherstellung
|
||||||
|
|
||||||
|
Forgot the admin password? The panel offers two self-service paths on the **forgot-password**
|
||||||
|
screen: an **e-mail reset link** (15 minutes valid) when SMTP is configured, and an inline
|
||||||
|
**2FA-proof reset** (e-mail + TOTP or a backup code + new password) as a fallback.
|
||||||
|
|
||||||
|
Locked out with **no 2FA and no SMTP**? As a last resort, SSH to the host and reset the admin
|
||||||
|
from the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.prod.yml exec app php artisan clusev:reset-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
This recovery command is documented in-panel under **Settings → Security** (admin-only) — it is
|
||||||
|
deliberately not advertised on the public, pre-login forgot-password screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.
|
AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ class ForgotPassword extends Component
|
||||||
|
|
||||||
public string $password_confirmation = '';
|
public string $password_confirmation = '';
|
||||||
|
|
||||||
|
/** Reveals the secondary inline 2FA-proof form when a mailer is configured. */
|
||||||
|
public bool $showCodeReset = false;
|
||||||
|
|
||||||
/** True when a real mailer is configured (then the email-link option is offered too). */
|
/** True when a real mailer is configured (then the email-link option is offered too). */
|
||||||
public function mailEnabled(): bool
|
public function mailEnabled(): bool
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ return [
|
||||||
'users' => [
|
'users' => [
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
'expire' => 60,
|
'expire' => 15,
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -96,15 +96,16 @@ return [
|
||||||
'forgot_link' => 'Passwort vergessen?',
|
'forgot_link' => 'Passwort vergessen?',
|
||||||
'forgot_heading' => 'Passwort zurücksetzen',
|
'forgot_heading' => 'Passwort zurücksetzen',
|
||||||
'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.',
|
'forgot_subtitle' => 'Setze dein Passwort mit deinem 2FA-Code oder einem Backup-Code zurück.',
|
||||||
|
'forgot_subtitle_mail' => 'Wir senden dir einen Reset-Link (15 Minuten gültig).',
|
||||||
'forgot_code' => '2FA-Code oder Backup-Code',
|
'forgot_code' => '2FA-Code oder Backup-Code',
|
||||||
'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.',
|
'forgot_code_hint' => '6-stelliger Authenticator-Code oder einer deiner Backup-Codes.',
|
||||||
'forgot_submit' => 'Passwort zurücksetzen',
|
'forgot_submit' => 'Passwort zurücksetzen',
|
||||||
'forgot_send_email' => 'Reset-Link per E-Mail senden',
|
'forgot_send_email' => 'Reset-Link senden',
|
||||||
|
'forgot_use_code_toggle' => 'Kein E-Mail-Zugang? Mit 2FA-Code zurücksetzen',
|
||||||
'reset_invalid' => 'E-Mail oder Code ungültig.',
|
'reset_invalid' => 'E-Mail oder Code ungültig.',
|
||||||
'reset_done' => 'Passwort geändert. Bitte neu anmelden.',
|
'reset_done' => 'Passwort geändert. Bitte neu anmelden.',
|
||||||
'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.',
|
'reset_link_sent' => 'Falls die E-Mail existiert, wurde ein Reset-Link versendet.',
|
||||||
'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.',
|
'reset_token_invalid' => 'Reset-Link ungültig oder abgelaufen.',
|
||||||
'reset_no_2fa_note' => 'Ohne 2FA ist die Wiederherstellung nur per E-Mail-Link (falls SMTP konfiguriert) oder über den Befehl clusev:reset-admin möglich.',
|
|
||||||
|
|
||||||
// ── WebAuthn / Security-Keys ─────────────────────────────────────────
|
// ── WebAuthn / Security-Keys ─────────────────────────────────────────
|
||||||
'webauthn_login' => 'Mit Security-Key anmelden',
|
'webauthn_login' => 'Mit Security-Key anmelden',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ return [
|
||||||
'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.',
|
'twofa_recommended' => '2FA ist optional, aber empfohlen — sichere dein Konto mit einem Authenticator oder Security-Key.',
|
||||||
'twofa_remove_totp' => 'Authenticator entfernen',
|
'twofa_remove_totp' => 'Authenticator entfernen',
|
||||||
|
|
||||||
|
// Account recovery (last-resort) note — admin-only, post-login
|
||||||
|
'recovery_note' => 'Konto-Wiederherstellung (letzter Ausweg): per SSH auf den Host einloggen und `clusev:reset-admin` ausführen.',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'profile_saved' => 'Profil gespeichert.',
|
'profile_saved' => 'Profil gespeichert.',
|
||||||
'password_changed' => 'Passwort geändert.',
|
'password_changed' => 'Passwort geändert.',
|
||||||
|
|
|
||||||
|
|
@ -96,15 +96,16 @@ return [
|
||||||
'forgot_link' => 'Forgot password?',
|
'forgot_link' => 'Forgot password?',
|
||||||
'forgot_heading' => 'Reset password',
|
'forgot_heading' => 'Reset password',
|
||||||
'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.',
|
'forgot_subtitle' => 'Reset your password with your 2FA code or a backup code.',
|
||||||
|
'forgot_subtitle_mail' => 'We will send you a reset link (valid for 15 minutes).',
|
||||||
'forgot_code' => '2FA code or backup code',
|
'forgot_code' => '2FA code or backup code',
|
||||||
'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.',
|
'forgot_code_hint' => 'Your 6-digit authenticator code or one of your backup codes.',
|
||||||
'forgot_submit' => 'Reset password',
|
'forgot_submit' => 'Reset password',
|
||||||
'forgot_send_email' => 'Email me a reset link',
|
'forgot_send_email' => 'Send reset link',
|
||||||
|
'forgot_use_code_toggle' => 'No email access? Reset with a 2FA code',
|
||||||
'reset_invalid' => 'Invalid email or code.',
|
'reset_invalid' => 'Invalid email or code.',
|
||||||
'reset_done' => 'Password changed. Please sign in again.',
|
'reset_done' => 'Password changed. Please sign in again.',
|
||||||
'reset_link_sent' => 'If the email exists, a reset link has been sent.',
|
'reset_link_sent' => 'If the email exists, a reset link has been sent.',
|
||||||
'reset_token_invalid' => 'Reset link is invalid or expired.',
|
'reset_token_invalid' => 'Reset link is invalid or expired.',
|
||||||
'reset_no_2fa_note' => 'Without 2FA, recovery is only possible via the email link (if SMTP is configured) or the clusev:reset-admin command.',
|
|
||||||
|
|
||||||
// ── WebAuthn / security keys ─────────────────────────────────────────
|
// ── WebAuthn / security keys ─────────────────────────────────────────
|
||||||
'webauthn_login' => 'Sign in with a security key',
|
'webauthn_login' => 'Sign in with a security key',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ return [
|
||||||
'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.',
|
'twofa_recommended' => '2FA is optional but recommended — secure your account with an authenticator or a security key.',
|
||||||
'twofa_remove_totp' => 'Remove authenticator',
|
'twofa_remove_totp' => 'Remove authenticator',
|
||||||
|
|
||||||
|
// Account recovery (last-resort) note — admin-only, post-login
|
||||||
|
'recovery_note' => 'Account recovery (last resort): sign in to the host over SSH and run `clusev:reset-admin`.',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'profile_saved' => 'Profile saved.',
|
'profile_saved' => 'Profile saved.',
|
||||||
'password_changed' => 'Password changed.',
|
'password_changed' => 'Password changed.',
|
||||||
|
|
|
||||||
|
|
@ -2,60 +2,82 @@
|
||||||
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
$label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
|
||||||
$field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
$field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
|
||||||
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
|
||||||
|
$mail = $this->mailEnabled();
|
||||||
|
// With SMTP the inline 2FA form is the secondary path (revealed via toggle); without it, it is the only path.
|
||||||
|
$showCode = $mail ? $showCodeReset : true;
|
||||||
@endphp
|
@endphp
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.security') }}</p>
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('auth.security') }}</p>
|
||||||
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.forgot_heading') }}</h1>
|
<h1 class="mt-1 font-display text-2xl font-semibold text-ink">{{ __('auth.forgot_heading') }}</h1>
|
||||||
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.forgot_subtitle') }}</p>
|
<p class="mt-1.5 text-sm text-ink-2">{{ $mail ? __('auth.forgot_subtitle_mail') : __('auth.forgot_subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form wire:submit="resetPassword" class="space-y-4">
|
@if ($mail)
|
||||||
<div>
|
{{-- SMTP configured: e-mail reset link is the default path. --}}
|
||||||
<label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
|
<form wire:submit="sendResetLink" class="space-y-4">
|
||||||
<input wire:model="email" id="email" type="text" autocomplete="username" class="{{ $field }}" placeholder="admin@clusev.local" />
|
<div>
|
||||||
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
<label for="email_link" class="{{ $label }}">{{ __('auth.email') }}</label>
|
||||||
</div>
|
<input wire:model="email" id="email_link" type="text" autocomplete="username" class="{{ $field }}" placeholder="admin@clusev.local" />
|
||||||
<div>
|
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
<label for="code" class="{{ $label }}">{{ __('auth.forgot_code') }}</label>
|
|
||||||
<input wire:model="code" id="code" type="text" inputmode="text" autocomplete="one-time-code" class="{{ $field }} font-mono" placeholder="123456 / xxxxxxxxxx-xxxxxxxxxx" />
|
|
||||||
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
|
||||||
<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.forgot_code_hint') }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
|
|
||||||
<div class="relative" x-data="{ show: false }">
|
|
||||||
<input wire:model="password" id="password" :type="show ? 'text' : 'password'" autocomplete="new-password" class="{{ $field }} pr-11" placeholder="••••••••" />
|
|
||||||
<button type="button" @click="show = !show" :aria-label="show ? '{{ __('auth.hide_password') }}' : '{{ __('auth.toggle_password') }}'"
|
|
||||||
class="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
|
|
||||||
<svg x-show="!show" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
<svg x-show="show" x-cloak class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="sendResetLink">
|
||||||
</div>
|
<span wire:loading.remove wire:target="sendResetLink">{{ __('auth.forgot_send_email') }}</span>
|
||||||
<div>
|
<span wire:loading wire:target="sendResetLink">{{ __('auth.saving') }}</span>
|
||||||
<label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label>
|
</x-btn>
|
||||||
<div class="relative" x-data="{ show: false }">
|
</form>
|
||||||
<input wire:model="password_confirmation" id="password_confirmation" :type="show ? 'text' : 'password'" autocomplete="new-password" class="{{ $field }} pr-11" placeholder="••••••••" />
|
|
||||||
<button type="button" @click="show = !show" :aria-label="show ? '{{ __('auth.hide_password') }}' : '{{ __('auth.toggle_password') }}'"
|
|
||||||
class="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
|
|
||||||
<svg x-show="!show" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
|
|
||||||
<svg x-show="show" x-cloak class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="resetPassword">
|
|
||||||
<span wire:loading.remove wire:target="resetPassword">{{ __('auth.forgot_submit') }}</span>
|
|
||||||
<span wire:loading wire:target="resetPassword">{{ __('auth.saving') }}</span>
|
|
||||||
</x-btn>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if ($this->mailEnabled())
|
@unless ($showCode)
|
||||||
<x-btn variant="secondary" class="w-full" wire:click="sendResetLink">{{ __('auth.forgot_send_email') }}</x-btn>
|
<button type="button" wire:click="$set('showCodeReset', true)"
|
||||||
|
class="block w-full text-center font-mono text-[11px] text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
|
||||||
|
{{ __('auth.forgot_use_code_toggle') }}
|
||||||
|
</button>
|
||||||
|
@endunless
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p class="font-mono text-[11px] text-ink-4">{{ __('auth.reset_no_2fa_note') }}</p>
|
@if ($showCode)
|
||||||
|
{{-- Inline 2FA-proof reset (email + TOTP/backup code + new password). --}}
|
||||||
|
<form wire:submit="resetPassword" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
|
||||||
|
<input wire:model="email" id="email" type="text" autocomplete="username" class="{{ $field }}" placeholder="admin@clusev.local" />
|
||||||
|
@error('email') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="code" class="{{ $label }}">{{ __('auth.forgot_code') }}</label>
|
||||||
|
<input wire:model="code" id="code" type="text" inputmode="text" autocomplete="one-time-code" class="{{ $field }} font-mono" placeholder="123456 / xxxxxxxxxx-xxxxxxxxxx" />
|
||||||
|
@error('code') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
|
<p class="mt-1.5 font-mono text-[11px] text-ink-4">{{ __('auth.forgot_code_hint') }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
|
||||||
|
<div class="relative" x-data="{ show: false }">
|
||||||
|
<input wire:model="password" id="password" :type="show ? 'text' : 'password'" autocomplete="new-password" class="{{ $field }} pr-11" placeholder="••••••••" />
|
||||||
|
<button type="button" @click="show = !show" :aria-label="show ? '{{ __('auth.hide_password') }}' : '{{ __('auth.toggle_password') }}'"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
|
||||||
|
<svg x-show="!show" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg x-show="show" x-cloak class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@error('password') <p class="{{ $err }}"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password_confirmation" class="{{ $label }}">{{ __('auth.confirm_new_password') }}</label>
|
||||||
|
<div class="relative" x-data="{ show: false }">
|
||||||
|
<input wire:model="password_confirmation" id="password_confirmation" :type="show ? 'text' : 'password'" autocomplete="new-password" class="{{ $field }} pr-11" placeholder="••••••••" />
|
||||||
|
<button type="button" @click="show = !show" :aria-label="show ? '{{ __('auth.hide_password') }}' : '{{ __('auth.toggle_password') }}'"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-11 items-center justify-center text-ink-3 transition-colors hover:text-accent-text focus:text-accent-text focus:outline-none">
|
||||||
|
<svg x-show="!show" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
<svg x-show="show" x-cloak class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<x-btn variant="primary" size="lg" type="submit" class="w-full" wire:loading.attr="disabled" wire:target="resetPassword">
|
||||||
|
<span wire:loading.remove wire:target="resetPassword">{{ __('auth.forgot_submit') }}</span>
|
||||||
|
<span wire:loading wire:target="resetPassword">{{ __('auth.saving') }}</span>
|
||||||
|
</x-btn>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
|
||||||
<a href="{{ route('login') }}" wire:navigate class="block text-center font-mono text-[11px] text-ink-3 transition-colors hover:text-ink-2">{{ __('auth.back_to_login') }}</a>
|
<a href="{{ route('login') }}" wire:navigate class="block text-center font-mono text-[11px] text-ink-3 transition-colors hover:text-ink-2">{{ __('auth.back_to_login') }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,9 @@
|
||||||
</x-panel>
|
</x-panel>
|
||||||
|
|
||||||
<livewire:settings.webauthn-keys />
|
<livewire:settings.webauthn-keys />
|
||||||
|
|
||||||
|
<p class="flex items-start gap-2 px-1 font-mono text-[11px] text-ink-4">
|
||||||
|
<x-icon name="shield" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>{{ __('settings.recovery_note') }}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Livewire\Auth\ForgotPassword;
|
||||||
|
use App\Livewire\Settings\Security;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use PragmaRX\Google2FAQRCode\Google2FA;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The forgot-password screen is mail-aware: with SMTP configured the e-mail reset link
|
||||||
|
* is the default path (with a secondary 2FA-fallback toggle); without SMTP only the
|
||||||
|
* inline 2FA-proof reset is shown. The public screen never advertises clusev:reset-admin
|
||||||
|
* — that last-resort recovery note lives in Settings → Security.
|
||||||
|
*/
|
||||||
|
class ForgotPasswordSmtpAwareTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
// ── Token expiry ─────────────────────────────────────────────────────
|
||||||
|
public function test_reset_token_expires_in_15_minutes(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(15, config('auth.passwords.users.expire'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SMTP configured: e-mail link is the default ──────────────────────
|
||||||
|
public function test_smtp_on_shows_send_link_button_and_hides_code_form_by_default(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertSee(__('auth.forgot_send_email'))
|
||||||
|
->assertSee(__('auth.forgot_use_code_toggle'))
|
||||||
|
// the inline 2FA-proof form (new-password field) is hidden by default
|
||||||
|
->assertDontSee(__('auth.new_password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_smtp_on_subtitle_advertises_the_email_link(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertSee(__('auth.forgot_subtitle_mail'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_smtp_on_send_link_dispatches_reset_notification(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
Notification::fake();
|
||||||
|
$user = User::factory()->create(['email' => 'admin@clusev.local']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->set('email', 'admin@clusev.local')
|
||||||
|
->call('sendResetLink')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, ResetPasswordNotification::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_toggle_reveals_the_2fa_code_form(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertDontSee(__('auth.new_password'))
|
||||||
|
->set('showCodeReset', true)
|
||||||
|
->assertSee(__('auth.new_password'))
|
||||||
|
->assertSee(__('auth.forgot_code'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_2fa_reset_still_works_with_smtp_on_via_toggle(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'admin@clusev.local',
|
||||||
|
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
]);
|
||||||
|
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->set('showCodeReset', true)
|
||||||
|
->set('email', $user->email)->set('code', $otp)
|
||||||
|
->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123')
|
||||||
|
->call('resetPassword')->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No SMTP: inline 2FA-proof reset only ─────────────────────────────
|
||||||
|
public function test_smtp_off_shows_inline_2fa_form_and_no_send_link(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'log']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertSee(__('auth.new_password'))
|
||||||
|
->assertSee(__('auth.forgot_code'))
|
||||||
|
->assertDontSee(__('auth.forgot_send_email'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_smtp_off_subtitle_keeps_the_2fa_copy(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'log']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertSee(__('auth.forgot_subtitle'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_smtp_off_resets_with_a_valid_totp(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'log']);
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'admin@clusev.local',
|
||||||
|
'two_factor_secret' => (new Google2FA)->generateSecretKey(),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
]);
|
||||||
|
$otp = (new Google2FA)->getCurrentOtp($user->two_factor_secret);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->set('email', $user->email)->set('code', $otp)
|
||||||
|
->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123')
|
||||||
|
->call('resetPassword')->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clusev:reset-admin recovery note placement ───────────────────────
|
||||||
|
public function test_public_forgot_view_does_not_advertise_reset_admin_command(): void
|
||||||
|
{
|
||||||
|
config(['mail.default' => 'log']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertDontSee('clusev:reset-admin');
|
||||||
|
|
||||||
|
config(['mail.default' => 'smtp']);
|
||||||
|
|
||||||
|
Livewire::test(ForgotPassword::class)
|
||||||
|
->assertDontSee('clusev:reset-admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_settings_security_shows_the_recovery_note_with_the_command(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
Livewire::actingAs($user)->test(Security::class)
|
||||||
|
->assertSee(__('settings.recovery_note'))
|
||||||
|
->assertSee('clusev:reset-admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue