KAN-22 Password confirmation pages #116

Merged
Twyco merged 1 commits from feature/KAN-22-password-confirmation-pages into main 2026-09-11 12:18:53 +00:00
7 changed files with 506 additions and 269 deletions
+48
View File
@@ -17,6 +17,54 @@ export const en = {
'auth.login.pending': 'Signing in…',
'auth.login.help': 'Need an account? Contact your workspace administrator.',
'auth.login.statusTitle': 'Account update',
'auth.forgot.title': 'Reset your password',
'auth.forgot.description':
'Enter your account email and we will send you a secure reset link.',
'auth.forgot.email': 'Email address',
'auth.forgot.emailPlaceholder': 'you@example.com',
'auth.forgot.emailHint':
'Use the email address associated with your KanbanGit account.',
'auth.forgot.submit': 'Send reset link',
'auth.forgot.pending': 'Sending reset link…',
'auth.forgot.sentTitle': 'Reset link sent',
'auth.reset.title': 'Choose a new password',
'auth.reset.description':
'Create a new password for the account linked below.',
'auth.reset.email': 'Account email',
'auth.reset.password': 'New password',
'auth.reset.passwordHint':
'Use a password you do not use for another account.',
'auth.reset.passwordConfirmation': 'Confirm new password',
'auth.reset.submit': 'Save new password',
'auth.reset.pending': 'Updating your password…',
'auth.reset.invalidTitle': 'This reset link is no longer valid',
'auth.reset.requestNew': 'Request a new reset link',
'auth.reset.footer':
'For your security, reset links expire and can only be used once.',
'auth.confirm.title': 'Confirm it is you',
'auth.confirm.description':
'This action affects sensitive account information. Enter your password to continue.',
'auth.confirm.password': 'Current password',
'auth.confirm.passwordHint':
'Your password is checked securely and is never saved in a draft.',
'auth.confirm.submit': 'Confirm and continue',
'auth.confirm.pending': 'Confirming your password…',
'auth.confirm.footer':
'Password confirmation expires automatically after a short time.',
'auth.forceChange.title': 'Secure your account',
'auth.forceChange.description':
'You need to replace your temporary password before continuing to KanbanGit.',
'auth.forceChange.currentPassword': 'Temporary password',
'auth.forceChange.password': 'New password',
'auth.forceChange.passwordHint':
'Choose a password that differs from your temporary password.',
'auth.forceChange.passwordConfirmation': 'Confirm new password',
'auth.forceChange.submit': 'Save password and continue',
'auth.forceChange.pending': 'Securing your account…',
'auth.forceChange.statusTitle': 'Password update',
'auth.forceChange.wrongAccount': 'Not the account you expected?',
'auth.forceChange.footer':
'Your password entries are kept in memory only and are cleared when you leave.',
'auth.verify.title': 'Check your inbox',
'auth.verify.description':
'Open the verification link in your email to finish confirming your account.',
+64 -39
View File
@@ -1,52 +1,77 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { ShieldCheck } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/password/confirm';
const { t } = useLocalization();
const form = useForm({ password: '' }).dontRemember('password');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void =>
protection.submit(store(), { onSuccess: () => form.reset() });
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Confirm your password"
description="This is a secure area of the application. Please confirm your password before continuing."
<AuthFlowLayout
:title="t('auth.confirm.title')"
:description="t('auth.confirm.description')"
:icon="ShieldCheck"
>
<Head title="Confirm password" />
<Form
v-bind="store.form()"
reset-on-success
v-slot="{ errors, processing }"
<Head :title="t('auth.confirm.title')" />
<form
:action="store.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
/>
<InputError :message="errors.password" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="confirm-password-button"
>
<Spinner v-if="processing" />
Confirm password
</Button>
</div>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.confirm.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.confirm.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="current-password"
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.confirm.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
</Form>
</AuthLayout>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.confirm.pending')"
data-test="confirm-password-button"
>{{ t('auth.confirm.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.confirm.footer') }}</template>
</AuthFlowLayout>
</template>
+155 -100
View File
@@ -1,115 +1,170 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next';
import { Head, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { logout } from '@/routes';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
withDefaults(
defineProps<{
status?: string | null;
}>(),
{
status: null,
},
);
withDefaults(defineProps<{ status?: string | null }>(), { status: null });
const { t } = useLocalization();
const form = useForm({
current_password: '',
password: '',
password_confirmation: '',
}).dontRemember('current_password', 'password', 'password_confirmation');
const logoutForm = useForm({});
const protection = useFormProtection(form, { isDirty: () => false });
const logoutProtection = useFormProtection(logoutForm);
const busy = computed(() => form.processing || logoutForm.processing);
const submit = (): void =>
protection.submit(SecurityController.update(), {
onSuccess: () => form.reset(),
});
const signOut = (): void => {
if (busy.value) {
return;
}
form.reset();
logoutProtection.submit(logout());
};
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Change your password"
description="You must update your password before you can continue using the application."
<AuthFlowLayout
:title="t('auth.forceChange.title')"
:description="t('auth.forceChange.description')"
:icon="KeyRound"
>
<Head title="Change your password" />
<div class="mb-6 flex justify-end">
<Button as-child variant="ghost" size="sm">
<Link :href="logout()" as="button">
<LogOut class="size-4" />
Log out
</Link>
</Button>
</div>
<div
<Head :title="t('auth.forceChange.title')" />
<FeedbackState
v-if="status"
class="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
state="success"
:title="t('auth.forceChange.statusTitle')"
:description="status"
/>
<form
:action="SecurityController.update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
{{ status }}
</div>
<Form
v-bind="SecurityController.update.form()"
reset-on-success
:reset-on-error="[
'password',
'password_confirmation',
'current_password',
]"
v-slot="{ errors, processing }"
>
<div class="space-y-6">
<div class="grid gap-2">
<Label htmlFor="current_password">Current password</Label>
<PasswordInput
id="current_password"
name="current_password"
class="mt-1 block w-full"
required
autocomplete="current-password"
autofocus
placeholder="Current password"
/>
<InputError :message="errors.current_password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password">New password</Label>
<PasswordInput
id="password"
name="password"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="New password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label htmlFor="password_confirmation">
Confirm new password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
class="mt-1 block w-full"
required
autocomplete="new-password"
placeholder="Confirm new password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<div class="flex items-center">
<Button
class="w-full"
:disabled="processing"
data-test="force-password-change-button"
>
<Spinner v-if="processing" />
Save new password
</Button>
</div>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forceChange.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="current_password">{{
t('auth.forceChange.currentPassword')
}}</Label>
<PasswordInput
id="current_password"
v-model="form.current_password"
name="current_password"
required
autocomplete="current-password"
autofocus
:disabled="busy"
:invalid="Boolean(form.errors.current_password)"
aria-describedby="current-password-error"
/>
<InputError
id="current-password-error"
:message="form.errors.current_password"
/>
</div>
</Form>
</AuthLayout>
<div class="gap-stack grid">
<Label for="password">{{
t('auth.forceChange.password')
}}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password)"
aria-describedby="new-password-hint new-password-error"
/>
<p
id="new-password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.forceChange.passwordHint') }}
</p>
<InputError
id="new-password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.forceChange.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
required
autocomplete="new-password"
:disabled="busy"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="new-password-confirmation-error"
/>
<InputError
id="new-password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="logoutForm.processing"
:loading="form.processing"
:loading-label="t('auth.forceChange.pending')"
data-test="force-password-change-button"
>{{ t('auth.forceChange.submit') }}</Button
>
</form>
<div class="gap-stack pt-column grid border-t">
<p class="text-body text-muted-foreground">
{{ t('auth.forceChange.wrongAccount') }}
</p>
<form
:action="logout.url()"
method="post"
class="gap-card grid"
@submit.prevent="signOut"
>
<AuthFormFeedback
:state="logoutProtection.state.value"
:response-status="logoutProtection.responseStatus.value"
:loading-title="t('auth.signingOut')"
@retry="signOut"
/>
<Button
type="submit"
variant="outline"
class="w-full"
:disabled="form.processing"
:loading="logoutForm.processing"
:loading-label="t('auth.signingOut')"
>{{ t('auth.signOut') }}</Button
>
</form>
</div>
<template #footer>{{ t('auth.forceChange.footer') }}</template>
</AuthFlowLayout>
</template>
+72 -48
View File
@@ -1,65 +1,89 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Head, useForm } from '@inertiajs/vue3';
import { Mail } from 'lucide-vue-next';
import { onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index';
import { email } from '@/routes/password';
defineProps<{
status?: string;
}>();
defineProps<{ status?: string }>();
const { t } = useLocalization();
const form = useForm({ email: '' }).dontRemember('email');
const protection = useFormProtection(form, { isDirty: () => false });
const submit = (): void => protection.submit(email());
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Forgot password"
description="Enter your email to receive a password reset link"
<AuthFlowLayout
:title="t('auth.forgot.title')"
:description="t('auth.forgot.description')"
:icon="Mail"
>
<Head title="Forgot password" />
<div
<Head :title="t('auth.forgot.title')" />
<FeedbackState
v-if="status"
class="mb-4 text-center text-sm font-medium text-green-600"
state="success"
:title="t('auth.forgot.sentTitle')"
:description="status"
/>
<form
:action="email.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
{{ status }}
</div>
<div class="space-y-6">
<Form v-bind="email.form()" v-slot="{ errors, processing }">
<div class="grid gap-2">
<Label for="email">Email address</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="off"
autofocus
placeholder="email@example.com"
/>
<InputError :message="errors.email" />
</div>
<div class="my-6 flex items-center justify-start">
<Button
class="w-full"
:disabled="processing"
data-test="email-password-reset-link-button"
>
<Spinner v-if="processing" />
Email password reset link
</Button>
</div>
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
<AuthFormFeedback
:state="protection.state.value"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.forgot.pending')"
@retry="submit"
/>
<div class="gap-stack grid">
<Label for="email">{{ t('auth.forgot.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
inputmode="email"
autocomplete="email"
autocapitalize="none"
:spellcheck="false"
required
autofocus
:disabled="form.processing"
:placeholder="t('auth.forgot.emailPlaceholder')"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-hint email-error"
/>
<p id="email-hint" class="text-caption text-muted-foreground">
{{ t('auth.forgot.emailHint') }}
</p>
<InputError id="email-error" :message="form.errors.email" />
</div>
</div>
</AuthLayout>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.forgot.pending')"
data-test="email-password-reset-link-button"
>{{ t('auth.forgot.submit') }}</Button
>
</form>
<template #footer
><TextLink :href="login()">{{
t('auth.backToLogin')
}}</TextLink></template
>
</AuthFlowLayout>
</template>
+122 -74
View File
@@ -1,88 +1,136 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import AuthLayout from '@/layouts/AuthLayout.vue';
import { update } from '@/routes/password';
import { useFormProtection } from '@/composables/useFormProtection';
import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
const props = defineProps<{
token: string;
email: string;
}>();
const inputEmail = ref(props.email);
const props = defineProps<{ token: string; email: string }>();
const { t } = useLocalization();
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
}).dontRemember('token', 'email', 'password', 'password_confirmation');
const protection = useFormProtection(form, { isDirty: () => false });
const invalidLink = computed(() => Boolean(form.errors.email));
const feedbackState = computed(() =>
invalidLink.value ? 'idle' : protection.state.value,
);
const submit = (): void =>
protection.submit(update(), {
onSuccess: () => form.reset('password', 'password_confirmation'),
});
onUnmounted(() => form.reset());
</script>
<template>
<AuthLayout
title="Reset password"
description="Please enter your new password below"
<AuthFlowLayout
:title="t('auth.reset.title')"
:description="t('auth.reset.description')"
:icon="KeyRound"
>
<Head title="Reset password" />
<Form
v-bind="update.form()"
:transform="(data) => ({ ...data, token, email })"
:reset-on-success="['password', 'password_confirmation']"
v-slot="{ errors, processing }"
<Head :title="t('auth.reset.title')" />
<FeedbackState
v-if="invalidLink"
state="error"
:title="t('auth.reset.invalidTitle')"
:description="form.errors.email"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
type="email"
name="email"
autocomplete="email"
v-model="inputEmail"
class="mt-1 block w-full"
readonly
/>
<InputError :message="errors.email" class="mt-2" />
</div>
<div class="grid gap-2">
<Label for="password">Password</Label>
<PasswordInput
id="password"
name="password"
autocomplete="new-password"
class="mt-1 block w-full"
autofocus
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<div class="grid gap-2">
<Label for="password_confirmation">
Confirm password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
autocomplete="new-password"
class="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError :message="errors.password_confirmation" />
</div>
<Button
type="submit"
class="mt-4 w-full"
:disabled="processing"
data-test="reset-password-button"
>
<Spinner v-if="processing" />
Reset password
</Button>
<Button as-child variant="outline" class="w-full">
<Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
</Button>
</FeedbackState>
<form
:action="update.url()"
method="post"
class="grid gap-5"
@submit.prevent="submit"
>
<AuthFormFeedback
:state="feedbackState"
:response-status="protection.responseStatus.value"
:loading-title="t('auth.reset.pending')"
@retry="submit"
/>
<input type="hidden" name="token" :value="form.token" />
<div class="gap-stack grid">
<Label for="email">{{ t('auth.reset.email') }}</Label>
<Input
id="email"
v-model="form.email"
name="email"
type="email"
autocomplete="username"
readonly
:disabled="form.processing"
:invalid="Boolean(form.errors.email)"
aria-describedby="email-error"
/>
<InputError id="email-error" :message="form.errors.email" />
</div>
</Form>
</AuthLayout>
<div class="gap-stack grid">
<Label for="password">{{ t('auth.reset.password') }}</Label>
<PasswordInput
id="password"
v-model="form.password"
name="password"
autocomplete="new-password"
required
autofocus
:disabled="form.processing"
:invalid="Boolean(form.errors.password)"
aria-describedby="password-hint password-error"
/>
<p
id="password-hint"
class="text-caption text-muted-foreground"
>
{{ t('auth.reset.passwordHint') }}
</p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div>
<div class="gap-stack grid">
<Label for="password_confirmation">{{
t('auth.reset.passwordConfirmation')
}}</Label>
<PasswordInput
id="password_confirmation"
v-model="form.password_confirmation"
name="password_confirmation"
autocomplete="new-password"
required
:disabled="form.processing"
:invalid="Boolean(form.errors.password_confirmation)"
aria-describedby="password-confirmation-error"
/>
<InputError
id="password-confirmation-error"
:message="form.errors.password_confirmation"
/>
</div>
<Button
type="submit"
class="w-full"
:loading="form.processing"
:loading-label="t('auth.reset.pending')"
data-test="reset-password-button"
>{{ t('auth.reset.submit') }}</Button
>
</form>
<template #footer>{{ t('auth.reset.footer') }}</template>
</AuthFlowLayout>
</template>
@@ -11,7 +11,7 @@ class PasswordConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirm_password_screen_can_be_rendered()
public function test_confirm_password_screen_can_be_rendered(): void
{
$user = User::factory()->create();
@@ -24,10 +24,32 @@ class PasswordConfirmationTest extends TestCase
);
}
public function test_password_confirmation_requires_authentication()
public function test_password_confirmation_requires_authentication(): void
{
$response = $this->get(route('password.confirm'));
$response->assertRedirect(route('login'));
}
public function test_password_can_be_confirmed(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post(route('password.confirm'), ['password' => 'password'])
->assertSessionHasNoErrors();
$this->assertNotNull(session('auth.password_confirmed_at'));
}
public function test_password_confirmation_rejects_an_invalid_password(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post(route('password.confirm'), ['password' => 'incorrect'])
->assertSessionHasErrors('password');
$this->assertNull(session('auth.password_confirmed_at'));
}
}
+21 -6
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -20,11 +21,16 @@ class PasswordResetTest extends TestCase
$this->skipUnlessFortifyFeature(Features::resetPasswords());
}
public function test_reset_password_link_screen_can_be_rendered()
public function test_reset_password_link_screen_can_be_rendered(): void
{
$response = $this->get(route('password.request'));
$response->assertOk();
$response
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('auth/ForgotPassword')
->where('status', null),
);
}
public function test_reset_password_link_can_be_requested()
@@ -38,7 +44,7 @@ class PasswordResetTest extends TestCase
Notification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_password_screen_can_be_rendered()
public function test_reset_password_screen_can_be_rendered(): void
{
Notification::fake();
@@ -46,10 +52,19 @@ class PasswordResetTest extends TestCase
$this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
$response = $this->get(route('password.reset', $notification->token));
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->get(route('password.reset', [
'token' => $notification->token,
'email' => $user->email,
]));
$response->assertOk();
$response
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('auth/ResetPassword')
->where('email', $user->email)
->where('token', $notification->token),
);
return true;
});