feat(auth): email reset-link path (gated on a configured mailer)

ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 16:57:05 +02:00
parent cb8b906aa8
commit abaed12b0b
5 changed files with 172 additions and 0 deletions

View File

@ -29,6 +29,20 @@ class ForgotPassword extends Component
return ! in_array(config('mail.default'), ['log', 'array', null], true); return ! in_array(config('mail.default'), ['log', 'array', null], true);
} }
/** Secondary path: email a reset link (only meaningful when a real mailer is set). */
public function sendResetLink(): void
{
if (! $this->mailEnabled()) {
return;
}
$this->validate(['email' => ['required', 'email']]);
\Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]);
// Generic — never reveal whether the email exists.
$this->dispatch('notify', message: __('auth.reset_link_sent'));
}
/** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */ /** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */
public function resetPassword() public function resetPassword()
{ {

View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.auth')]
class ResetPassword extends Component
{
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public function mount(string $token): void
{
$this->token = $token;
$this->email = (string) request()->query('email', '');
}
/** Consume the emailed reset token and set a new password. */
public function resetPassword()
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()],
]);
$status = Password::reset(
[
'email' => $this->email,
'password' => $this->password,
'password_confirmation' => $this->password_confirmation,
'token' => $this->token,
],
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'must_change_password' => false,
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
},
);
if ($status !== Password::PasswordReset) {
throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]);
}
session()->flash('status', __('auth.reset_done'));
return $this->redirect(route('login'), navigate: true);
}
public function render()
{
return view('livewire.auth.reset-password')->title(__('auth.title_forgot'));
}
}

View File

@ -0,0 +1,35 @@
@php
$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';
$err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline';
@endphp
<div class="space-y-5">
<div>
<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>
<p class="mt-1.5 text-sm text-ink-2">{{ __('auth.forgot_subtitle') }}</p>
</div>
<form wire:submit="resetPassword" class="space-y-4">
<div>
<label for="email" class="{{ $label }}">{{ __('auth.email') }}</label>
<input wire:model="email" id="email" type="email" 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="password" class="{{ $label }}">{{ __('auth.new_password') }}</label>
<input wire:model="password" id="password" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
@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>
<input wire:model="password_confirmation" id="password_confirmation" type="password" autocomplete="new-password" class="{{ $field }}" placeholder="••••••••" />
</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>
<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>

View File

@ -44,6 +44,7 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/login', Auth\Login::class)->name('login'); Route::get('/login', Auth\Login::class)->name('login');
Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');
}); });

View File

@ -0,0 +1,52 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\ResetPassword;
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 Illuminate\Support\Facades\Password;
use Livewire\Livewire;
use Tests\TestCase;
class EmailResetTest extends TestCase
{
use RefreshDatabase;
public function test_link_option_hidden_when_mailer_is_log(): void
{
config(['mail.default' => 'log']);
$this->assertFalse((new ForgotPassword)->mailEnabled());
}
public function test_send_link_dispatches_notification_when_mail_configured(): 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_reset_password_with_token_sets_new_password(): void
{
$user = User::factory()->create();
$token = Password::createToken($user);
Livewire::test(ResetPassword::class, ['token' => $token])
->set('email', $user->email)
->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123')
->call('resetPassword')->assertHasNoErrors();
$this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password));
}
}