feat(KAN-22): rebuild password confirmation flows
This commit is contained in:
@@ -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.',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user