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
parent
cb8b906aa8
commit
abaed12b0b
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue