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.pending': 'Signing in…',
'auth.login.help': 'Need an account? Contact your workspace administrator.', 'auth.login.help': 'Need an account? Contact your workspace administrator.',
'auth.login.statusTitle': 'Account update', '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.title': 'Check your inbox',
'auth.verify.description': 'auth.verify.description':
'Open the verification link in your email to finish confirming your account.', 'Open the verification link in your email to finish confirming your account.',
+64 -39
View File
@@ -1,52 +1,77 @@
<script setup lang="ts"> <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 InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { store } from '@/routes/password/confirm'; 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> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Confirm your password" :title="t('auth.confirm.title')"
description="This is a secure area of the application. Please confirm your password before continuing." :description="t('auth.confirm.description')"
:icon="ShieldCheck"
> >
<Head title="Confirm password" /> <Head :title="t('auth.confirm.title')" />
<form
<Form :action="store.url()"
v-bind="store.form()" method="post"
reset-on-success class="grid gap-5"
v-slot="{ errors, processing }" @submit.prevent="submit"
> >
<div class="space-y-6"> <AuthFormFeedback
<div class="grid gap-2"> :state="protection.state.value"
<Label htmlFor="password">Password</Label> :response-status="protection.responseStatus.value"
<PasswordInput :loading-title="t('auth.confirm.pending')"
id="password" @retry="submit"
name="password" />
class="mt-1 block w-full" <div class="gap-stack grid">
required <Label for="password">{{ t('auth.confirm.password') }}</Label>
autocomplete="current-password" <PasswordInput
autofocus id="password"
/> v-model="form.password"
name="password"
<InputError :message="errors.password" /> required
</div> autocomplete="current-password"
autofocus
<div class="flex items-center"> :disabled="form.processing"
<Button :invalid="Boolean(form.errors.password)"
class="w-full" aria-describedby="password-hint password-error"
:disabled="processing" />
data-test="confirm-password-button" <p
> id="password-hint"
<Spinner v-if="processing" /> class="text-caption text-muted-foreground"
Confirm password >
</Button> {{ t('auth.confirm.passwordHint') }}
</div> </p>
<InputError
id="password-error"
:message="form.errors.password"
/>
</div> </div>
</Form> <Button
</AuthLayout> 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> </template>
+155 -100
View File
@@ -1,115 +1,170 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { LogOut } from 'lucide-vue-next'; import { KeyRound } from 'lucide-vue-next';
import { computed, onUnmounted } from 'vue';
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController'; 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 InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { logout } from '@/routes'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { logout } from '@/routes/index';
withDefaults( withDefaults(defineProps<{ status?: string | null }>(), { status: null });
defineProps<{ const { t } = useLocalization();
status?: string | null; const form = useForm({
}>(), current_password: '',
{ password: '',
status: null, 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> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Change your password" :title="t('auth.forceChange.title')"
description="You must update your password before you can continue using the application." :description="t('auth.forceChange.description')"
:icon="KeyRound"
> >
<Head title="Change your password" /> <Head :title="t('auth.forceChange.title')" />
<FeedbackState
<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
v-if="status" 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 }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<Form :loading-title="t('auth.forceChange.pending')"
v-bind="SecurityController.update.form()" @retry="submit"
reset-on-success />
:reset-on-error="[ <div class="gap-stack grid">
'password', <Label for="current_password">{{
'password_confirmation', t('auth.forceChange.currentPassword')
'current_password', }}</Label>
]" <PasswordInput
v-slot="{ errors, processing }" id="current_password"
> v-model="form.current_password"
<div class="space-y-6"> name="current_password"
<div class="grid gap-2"> required
<Label htmlFor="current_password">Current password</Label> autocomplete="current-password"
<PasswordInput autofocus
id="current_password" :disabled="busy"
name="current_password" :invalid="Boolean(form.errors.current_password)"
class="mt-1 block w-full" aria-describedby="current-password-error"
required />
autocomplete="current-password" <InputError
autofocus id="current-password-error"
placeholder="Current password" :message="form.errors.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>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <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> </template>
+72 -48
View File
@@ -1,65 +1,89 @@
<script setup lang="ts"> <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 InputError from '@/components/InputError.vue';
import TextLink from '@/components/TextLink.vue'; import TextLink from '@/components/TextLink.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { login } from '@/routes/index'; import { login } from '@/routes/index';
import { email } from '@/routes/password'; import { email } from '@/routes/password';
defineProps<{ defineProps<{ status?: string }>();
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> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Forgot password" :title="t('auth.forgot.title')"
description="Enter your email to receive a password reset link" :description="t('auth.forgot.description')"
:icon="Mail"
> >
<Head title="Forgot password" /> <Head :title="t('auth.forgot.title')" />
<FeedbackState
<div
v-if="status" 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 }} <AuthFormFeedback
</div> :state="protection.state.value"
:response-status="protection.responseStatus.value"
<div class="space-y-6"> :loading-title="t('auth.forgot.pending')"
<Form v-bind="email.form()" v-slot="{ errors, processing }"> @retry="submit"
<div class="grid gap-2"> />
<Label for="email">Email address</Label> <div class="gap-stack grid">
<Input <Label for="email">{{ t('auth.forgot.email') }}</Label>
id="email" <Input
type="email" id="email"
name="email" v-model="form.email"
autocomplete="off" name="email"
autofocus type="email"
placeholder="email@example.com" inputmode="email"
/> autocomplete="email"
<InputError :message="errors.email" /> autocapitalize="none"
</div> :spellcheck="false"
required
<div class="my-6 flex items-center justify-start"> autofocus
<Button :disabled="form.processing"
class="w-full" :placeholder="t('auth.forgot.emailPlaceholder')"
:disabled="processing" :invalid="Boolean(form.errors.email)"
data-test="email-password-reset-link-button" aria-describedby="email-hint email-error"
> />
<Spinner v-if="processing" /> <p id="email-hint" class="text-caption text-muted-foreground">
Email password reset link {{ t('auth.forgot.emailHint') }}
</Button> </p>
</div> <InputError id="email-error" :message="form.errors.email" />
</Form>
<div class="text-muted-foreground space-x-1 text-center text-sm">
<span>Or, return to</span>
<TextLink :href="login()">log in</TextLink>
</div> </div>
</div> <Button
</AuthLayout> 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> </template>
+122 -74
View File
@@ -1,88 +1,136 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue'; 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 InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue'; import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner'; import { useFormProtection } from '@/composables/useFormProtection';
import AuthLayout from '@/layouts/AuthLayout.vue'; import { useLocalization } from '@/composables/useLocalization';
import { update } from '@/routes/password'; import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
import { request, update } from '@/routes/password';
const props = defineProps<{ const props = defineProps<{ token: string; email: string }>();
token: string; const { t } = useLocalization();
email: string; const form = useForm({
}>(); token: props.token,
email: props.email,
const inputEmail = ref(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> </script>
<template> <template>
<AuthLayout <AuthFlowLayout
title="Reset password" :title="t('auth.reset.title')"
description="Please enter your new password below" :description="t('auth.reset.description')"
:icon="KeyRound"
> >
<Head title="Reset password" /> <Head :title="t('auth.reset.title')" />
<FeedbackState
<Form v-if="invalidLink"
v-bind="update.form()" state="error"
:transform="(data) => ({ ...data, token, email })" :title="t('auth.reset.invalidTitle')"
:reset-on-success="['password', 'password_confirmation']" :description="form.errors.email"
v-slot="{ errors, processing }"
> >
<div class="grid gap-6"> <Button as-child variant="outline" class="w-full">
<div class="grid gap-2"> <Link :href="request()">{{ t('auth.reset.requestNew') }}</Link>
<Label for="email">Email</Label> </Button>
<Input </FeedbackState>
id="email" <form
type="email" :action="update.url()"
name="email" method="post"
autocomplete="email" class="grid gap-5"
v-model="inputEmail" @submit.prevent="submit"
class="mt-1 block w-full" >
readonly <AuthFormFeedback
/> :state="feedbackState"
<InputError :message="errors.email" class="mt-2" /> :response-status="protection.responseStatus.value"
</div> :loading-title="t('auth.reset.pending')"
@retry="submit"
<div class="grid gap-2"> />
<Label for="password">Password</Label> <input type="hidden" name="token" :value="form.token" />
<PasswordInput <div class="gap-stack grid">
id="password" <Label for="email">{{ t('auth.reset.email') }}</Label>
name="password" <Input
autocomplete="new-password" id="email"
class="mt-1 block w-full" v-model="form.email"
autofocus name="email"
placeholder="Password" type="email"
/> autocomplete="username"
<InputError :message="errors.password" /> readonly
</div> :disabled="form.processing"
:invalid="Boolean(form.errors.email)"
<div class="grid gap-2"> aria-describedby="email-error"
<Label for="password_confirmation"> />
Confirm password <InputError id="email-error" :message="form.errors.email" />
</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>
</div> </div>
</Form> <div class="gap-stack grid">
</AuthLayout> <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> </template>
@@ -11,7 +11,7 @@ class PasswordConfirmationTest extends TestCase
{ {
use RefreshDatabase; 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(); $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 = $this->get(route('password.confirm'));
$response->assertRedirect(route('login')); $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\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
use Tests\TestCase; use Tests\TestCase;
@@ -20,11 +21,16 @@ class PasswordResetTest extends TestCase
$this->skipUnlessFortifyFeature(Features::resetPasswords()); $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 = $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() public function test_reset_password_link_can_be_requested()
@@ -38,7 +44,7 @@ class PasswordResetTest extends TestCase
Notification::assertSentTo($user, ResetPassword::class); 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(); Notification::fake();
@@ -46,10 +52,19 @@ class PasswordResetTest extends TestCase
$this->post(route('password.email'), ['email' => $user->email]); $this->post(route('password.email'), ['email' => $user->email]);
Notification::assertSentTo($user, ResetPassword::class, function ($notification) { Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
$response = $this->get(route('password.reset', $notification->token)); $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; return true;
}); });