KAN-21 Login, verification, and two-factor authentication #115
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import FormFeedback from '@/components/FormFeedback.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
import { login } from '@/routes/index';
|
||||
|
||||
const props = defineProps<{
|
||||
state: FormFeedbackState;
|
||||
responseStatus?: number | null;
|
||||
loadingTitle?: string;
|
||||
successTitle?: string;
|
||||
}>();
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const expired = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 419,
|
||||
);
|
||||
const limited = computed(
|
||||
() => props.state === 'server' && props.responseStatus === 429,
|
||||
);
|
||||
const title = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredTitle');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedTitle');
|
||||
}
|
||||
if (props.state === 'loading') {
|
||||
return props.loadingTitle ?? t('auth.feedback.pending');
|
||||
}
|
||||
if (props.state === 'success') {
|
||||
return props.successTitle ?? t('auth.feedback.completed');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationTitle');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const description = computed(() => {
|
||||
if (expired.value) {
|
||||
return t('auth.feedback.expiredDescription');
|
||||
}
|
||||
if (limited.value) {
|
||||
return t('auth.feedback.limitedDescription');
|
||||
}
|
||||
if (props.state === 'validation') {
|
||||
return t('auth.feedback.validationDescription');
|
||||
}
|
||||
if (props.state === 'network') {
|
||||
return t('auth.feedback.networkDescription');
|
||||
}
|
||||
if (props.state === 'server') {
|
||||
return t('auth.feedback.serverDescription');
|
||||
}
|
||||
if (props.state === 'cancelled') {
|
||||
return t('auth.feedback.cancelledDescription');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-card grid" v-show="state !== 'idle'">
|
||||
<FormFeedback
|
||||
:state="state"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:retryable="!expired"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
<Button v-if="expired" as-child variant="outline">
|
||||
<!-- A fresh document renews the session/CSRF state and drops in-memory secrets. -->
|
||||
<a :href="login.url()">{{ t('auth.backToLogin') }}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,7 +4,15 @@ import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import type { FormFeedbackState } from '@/lib/form-feedback';
|
||||
|
||||
const props = defineProps<{ state: FormFeedbackState }>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: FormFeedbackState;
|
||||
title?: string;
|
||||
description?: string;
|
||||
retryable?: boolean;
|
||||
}>(),
|
||||
{ retryable: true },
|
||||
);
|
||||
defineEmits<{ retry: [] }>();
|
||||
const { t } = useLocalization();
|
||||
const feedback = useTemplateRef<HTMLDivElement>('feedback');
|
||||
@@ -54,12 +62,13 @@ const description = computed(() =>
|
||||
:state="
|
||||
state === 'loading' || state === 'success' ? state : 'error'
|
||||
"
|
||||
:title="t(titles[state])"
|
||||
:description="description"
|
||||
:title="props.title ?? t(titles[state])"
|
||||
:description="props.description ?? description"
|
||||
:retryable="
|
||||
state === 'network' ||
|
||||
state === 'server' ||
|
||||
state === 'cancelled'
|
||||
props.retryable !== false &&
|
||||
(state === 'network' ||
|
||||
state === 'server' ||
|
||||
state === 'cancelled')
|
||||
"
|
||||
@retry="$emit('retry')"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Eye, EyeOff } from 'lucide-vue-next';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import type { HTMLAttributes } from 'vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -9,12 +10,14 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class'];
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string | number | null>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
const inputRef = useTemplateRef('inputRef');
|
||||
const { t } = useLocalization();
|
||||
|
||||
defineExpose({
|
||||
$el: inputRef,
|
||||
@@ -28,7 +31,8 @@ defineExpose({
|
||||
ref="inputRef"
|
||||
v-model="model"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:class="cn('pr-10', props.class)"
|
||||
:class="cn('pr-12', props.class)"
|
||||
:disabled="disabled"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<button
|
||||
@@ -36,11 +40,14 @@ defineExpose({
|
||||
@click="showPassword = !showPassword"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]',
|
||||
'text-muted-foreground hover:text-foreground focus-visible:ring-ring w-control absolute inset-y-0 right-0 flex items-center justify-center rounded-r-md focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
)
|
||||
"
|
||||
:aria-label="showPassword ? 'Hide password' : 'Show password'"
|
||||
:tabindex="-1"
|
||||
:aria-label="
|
||||
t(showPassword ? 'auth.password.hide' : 'auth.password.show')
|
||||
"
|
||||
:aria-pressed="showPassword"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<EyeOff v-if="showPassword" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
|
||||
@@ -20,6 +20,7 @@ export function useFormProtection<T extends object>(
|
||||
options: { isDirty?: () => boolean } = {},
|
||||
) {
|
||||
const failure = ref<FormFeedbackState | null>(null);
|
||||
const responseStatus = ref<number | null>(null);
|
||||
let ownVisit: PendingVisit | null = null;
|
||||
let requestId: string | null = null;
|
||||
const removeListeners: Array<() => void> = [];
|
||||
@@ -56,6 +57,7 @@ export function useFormProtection<T extends object>(
|
||||
) {
|
||||
event.preventDefault();
|
||||
failure.value = 'server';
|
||||
responseStatus.value = event.detail.response.status;
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -88,6 +90,7 @@ export function useFormProtection<T extends object>(
|
||||
onStart: (visit) => {
|
||||
requestId = nextRequestId;
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
visitOptions.onStart?.(visit);
|
||||
},
|
||||
onError: (errors) => {
|
||||
@@ -110,5 +113,10 @@ export function useFormProtection<T extends object>(
|
||||
});
|
||||
}
|
||||
|
||||
return { state, submit, confirmLeave };
|
||||
function clearFeedback(): void {
|
||||
failure.value = null;
|
||||
responseStatus.value = null;
|
||||
}
|
||||
|
||||
return { state, responseStatus, submit, confirmLeave, clearFeedback };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { ShieldCheck } from 'lucide-vue-next';
|
||||
import type { Component } from 'vue';
|
||||
import AppLogo from '@/components/AppLogo.vue';
|
||||
import AppearanceTabs from '@/components/AppearanceTabs.vue';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import { home } from '@/routes/index';
|
||||
|
||||
defineProps<{ title: string; description: string; icon?: Component }>();
|
||||
const { t } = useLocalization();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background text-foreground flex min-h-dvh flex-col">
|
||||
<header
|
||||
class="bg-card px-page flex justify-center border-b py-4 sm:justify-start"
|
||||
>
|
||||
<Link
|
||||
:href="home()"
|
||||
:aria-label="t('auth.home')"
|
||||
class="focus-visible:ring-ring/35 rounded-md outline-none focus-visible:ring-2"
|
||||
><AppLogo
|
||||
/></Link>
|
||||
</header>
|
||||
<main
|
||||
class="gap-column sm:px-page mx-auto flex w-full max-w-md flex-1 flex-col justify-center px-4 py-8 sm:py-12"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="auth-title"
|
||||
class="bg-card p-page text-card-foreground shadow-xs grid gap-6 rounded-md border"
|
||||
>
|
||||
<div class="gap-column grid">
|
||||
<span
|
||||
class="bg-primary/10 text-primary flex size-11 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="icon ?? ShieldCheck"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div class="gap-stack grid">
|
||||
<p
|
||||
class="text-label text-muted-foreground uppercase tracking-wide"
|
||||
>
|
||||
{{ t('auth.accountAccess') }}
|
||||
</p>
|
||||
<h1
|
||||
id="auth-title"
|
||||
class="text-headline-mobile sm:text-headline"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="px-card text-body text-muted-foreground text-center"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="pb-page flex justify-center px-4">
|
||||
<AppearanceTabs />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,67 @@
|
||||
export const en = {
|
||||
'auth.home': 'KanbanGit home',
|
||||
'auth.accountAccess': 'Account access',
|
||||
'auth.backToLogin': 'Return to sign in',
|
||||
'auth.signOut': 'Sign out',
|
||||
'auth.signingOut': 'Signing out…',
|
||||
'auth.password.show': 'Show password',
|
||||
'auth.password.hide': 'Hide password',
|
||||
'auth.login.title': 'Welcome back',
|
||||
'auth.login.description': 'Sign in to continue to your workspace.',
|
||||
'auth.login.email': 'Email address',
|
||||
'auth.login.emailPlaceholder': 'you@example.com',
|
||||
'auth.login.password': 'Password',
|
||||
'auth.login.remember': 'Remember me',
|
||||
'auth.login.forgot': 'Forgot password?',
|
||||
'auth.login.submit': 'Sign in',
|
||||
'auth.login.pending': 'Signing in…',
|
||||
'auth.login.help': 'Need an account? Contact your workspace administrator.',
|
||||
'auth.login.statusTitle': 'Account update',
|
||||
'auth.verify.title': 'Check your inbox',
|
||||
'auth.verify.description':
|
||||
'Open the verification link in your email to finish confirming your account.',
|
||||
'auth.verify.emailLabel': 'Verification email address',
|
||||
'auth.verify.resend': 'Resend verification email',
|
||||
'auth.verify.pending': 'Sending verification email…',
|
||||
'auth.verify.sentTitle': 'Verification link sent',
|
||||
'auth.verify.sentDescription':
|
||||
'Check your inbox for a new link. If it does not arrive, check your spam folder.',
|
||||
'auth.verify.help': 'Already verified in another tab?',
|
||||
'auth.verify.continue': 'Continue to workspace',
|
||||
'auth.verify.wrongAccount':
|
||||
'Wrong account? Sign out and use a different email address.',
|
||||
'auth.twoFactor.title': 'Two-step verification',
|
||||
'auth.twoFactor.description':
|
||||
'Enter the six-digit code from your authenticator app to finish signing in.',
|
||||
'auth.twoFactor.code': 'Authentication code',
|
||||
'auth.twoFactor.codeHint':
|
||||
'Enter all six digits, including any leading zero.',
|
||||
'auth.twoFactor.recoveryTitle': 'Use a recovery code',
|
||||
'auth.twoFactor.recoveryDescription':
|
||||
'Enter one of the recovery codes you saved when you set up two-factor authentication.',
|
||||
'auth.twoFactor.recoveryCode': 'Recovery code',
|
||||
'auth.twoFactor.recoveryHint': 'Each recovery code can only be used once.',
|
||||
'auth.twoFactor.useRecovery': 'Use a recovery code instead',
|
||||
'auth.twoFactor.useCode': 'Use an authenticator code instead',
|
||||
'auth.twoFactor.submit': 'Verify and sign in',
|
||||
'auth.twoFactor.pending': 'Verifying your code…',
|
||||
'auth.feedback.pending': 'Please wait…',
|
||||
'auth.feedback.completed': 'Request completed',
|
||||
'auth.feedback.validationTitle': 'Check your details',
|
||||
'auth.feedback.validationDescription':
|
||||
'Review the message below and try again. Your entries have been kept.',
|
||||
'auth.feedback.expiredTitle': 'Your session has expired',
|
||||
'auth.feedback.expiredDescription':
|
||||
'Return to sign in to start a new session. You will need to enter your details again.',
|
||||
'auth.feedback.limitedTitle': 'Too many attempts',
|
||||
'auth.feedback.limitedDescription':
|
||||
'Please wait a moment before trying again.',
|
||||
'auth.feedback.networkDescription':
|
||||
'We could not confirm the result. Your entries are still here. Check your connection before trying again.',
|
||||
'auth.feedback.serverDescription':
|
||||
'The server returned an unexpected response. Your entries are still here. Please try again in a moment.',
|
||||
'auth.feedback.cancelledDescription':
|
||||
'The request was interrupted. Your entries are still here if you want to try again.',
|
||||
'navigation.main': 'Main navigation',
|
||||
'navigation.workspace': 'Workspace',
|
||||
'navigation.project': 'Project navigation',
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { LogIn } 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 PasswordInput from '@/components/PasswordInput.vue';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
@@ -7,94 +11,114 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthBase 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/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
}>();
|
||||
defineProps<{ status?: string; canResetPassword: boolean }>();
|
||||
const { t } = useLocalization();
|
||||
// Credentials stay in memory only; no remembered form key or browser storage.
|
||||
const form = useForm({ email: '', password: '', remember: false }).dontRemember(
|
||||
'password',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const submit = (): void =>
|
||||
protection.submit(store(), {
|
||||
onSuccess: () => form.reset('password'),
|
||||
});
|
||||
onUnmounted(() => form.reset('password'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthBase
|
||||
title="Log in to your account"
|
||||
description="Enter your email and password below to log in"
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.login.title')"
|
||||
:description="t('auth.login.description')"
|
||||
:icon="LogIn"
|
||||
>
|
||||
<Head title="Log in" />
|
||||
|
||||
<div
|
||||
<Head :title="t('auth.login.submit')" />
|
||||
<FeedbackState
|
||||
v-if="status"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
state="success"
|
||||
:title="t('auth.login.statusTitle')"
|
||||
:description="status"
|
||||
/>
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
:reset-on-success="['password']"
|
||||
v-slot="{ errors, processing }"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<div class="grid gap-6">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autofocus
|
||||
:tabindex="1"
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError :message="errors.email" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="password">Password</Label>
|
||||
<TextLink
|
||||
v-if="canResetPassword"
|
||||
:href="request()"
|
||||
class="text-sm"
|
||||
:tabindex="5"
|
||||
>
|
||||
Forgot password?
|
||||
</TextLink>
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
:tabindex="2"
|
||||
autocomplete="current-password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError :message="errors.password" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="remember" class="flex items-center space-x-3">
|
||||
<Checkbox id="remember" name="remember" :tabindex="3" />
|
||||
<span>Remember me</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
class="mt-4 w-full"
|
||||
:tabindex="4"
|
||||
:disabled="processing"
|
||||
data-test="login-button"
|
||||
>
|
||||
<Spinner v-if="processing" />
|
||||
Log in
|
||||
</Button>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.login.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="email">{{ t('auth.login.email') }}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:placeholder="t('auth.login.emailPlaceholder')"
|
||||
:invalid="Boolean(form.errors.email)"
|
||||
aria-describedby="email-error"
|
||||
/>
|
||||
<InputError id="email-error" :message="form.errors.email" />
|
||||
</div>
|
||||
</Form>
|
||||
</AuthBase>
|
||||
<div class="gap-stack grid">
|
||||
<Label for="password">{{ t('auth.login.password') }}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(form.errors.password)"
|
||||
aria-describedby="password-error"
|
||||
/>
|
||||
<InputError
|
||||
id="password-error"
|
||||
:message="form.errors.password"
|
||||
/>
|
||||
</div>
|
||||
<div class="gap-card flex flex-wrap items-center justify-between">
|
||||
<Label for="remember" class="min-h-control gap-stack">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
v-model="form.remember"
|
||||
name="remember"
|
||||
:disabled="form.processing"
|
||||
/>
|
||||
{{ t('auth.login.remember') }}
|
||||
</Label>
|
||||
<TextLink
|
||||
v-if="canResetPassword"
|
||||
:href="request()"
|
||||
class="min-h-control text-body inline-flex items-center"
|
||||
>{{ t('auth.login.forgot') }}</TextLink
|
||||
>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.login.pending')"
|
||||
data-test="login-button"
|
||||
>{{ t('auth.login.submit') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.login.help') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,133 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { KeyRound, ShieldCheck } from 'lucide-vue-next';
|
||||
import { computed, nextTick, onUnmounted, ref, useTemplateRef } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.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 {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { login } from '@/routes/index';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
const authConfigContent = computed<TwoFactorConfigContent>(() => {
|
||||
if (showRecoveryInput.value) {
|
||||
return {
|
||||
title: 'Recovery code',
|
||||
description:
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
buttonText: 'login using an authentication code',
|
||||
};
|
||||
const { t } = useLocalization();
|
||||
const recovery = ref(false);
|
||||
const form = useForm({ code: '', recovery_code: '' }).dontRemember(
|
||||
'code',
|
||||
'recovery_code',
|
||||
);
|
||||
const protection = useFormProtection(form, { isDirty: () => false });
|
||||
const codeInput = useTemplateRef('codeInput');
|
||||
const recoveryInput = useTemplateRef('recoveryInput');
|
||||
const fieldError = computed(() =>
|
||||
recovery.value
|
||||
? (form.errors.recovery_code ?? form.errors.code)
|
||||
: form.errors.code,
|
||||
);
|
||||
const toggleMode = async (): Promise<void> => {
|
||||
if (form.processing) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
buttonText: 'login using a recovery code',
|
||||
};
|
||||
});
|
||||
|
||||
const showRecoveryInput = ref<boolean>(false);
|
||||
|
||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||
showRecoveryInput.value = !showRecoveryInput.value;
|
||||
clearErrors();
|
||||
code.value = '';
|
||||
recovery.value = !recovery.value;
|
||||
form.resetAndClearErrors();
|
||||
protection.clearFeedback();
|
||||
await nextTick();
|
||||
(recovery.value ? recoveryInput.value : codeInput.value)?.$el?.focus();
|
||||
};
|
||||
|
||||
const code = ref<string>('');
|
||||
const pasteCode = (event: ClipboardEvent): void => {
|
||||
const code = event.clipboardData?.getData('text').replace(/\s/g, '') ?? '';
|
||||
if (/^[0-9]{6}$/.test(code)) {
|
||||
event.preventDefault();
|
||||
form.code = code;
|
||||
}
|
||||
};
|
||||
const submit = (): void => {
|
||||
form.transform((data) =>
|
||||
recovery.value
|
||||
? { recovery_code: data.recovery_code.trim() }
|
||||
: { code: data.code },
|
||||
);
|
||||
protection.submit(store(), { onSuccess: () => form.reset() });
|
||||
};
|
||||
onUnmounted(() => form.reset());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
:title="authConfigContent.title"
|
||||
:description="authConfigContent.description"
|
||||
<AuthFlowLayout
|
||||
:title="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryTitle'
|
||||
: 'auth.twoFactor.title',
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.recoveryDescription'
|
||||
: 'auth.twoFactor.description',
|
||||
)
|
||||
"
|
||||
:icon="recovery ? KeyRound : ShieldCheck"
|
||||
>
|
||||
<Head title="Two-factor authentication" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<template v-if="!showRecoveryInput">
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
@error="code = ''"
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
<Head :title="t('auth.twoFactor.title')" />
|
||||
<form
|
||||
:action="store.url()"
|
||||
method="post"
|
||||
class="grid gap-5"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<AuthFormFeedback
|
||||
:state="protection.state.value"
|
||||
:response-status="protection.responseStatus.value"
|
||||
:loading-title="t('auth.twoFactor.pending')"
|
||||
@retry="submit"
|
||||
/>
|
||||
<div v-if="!recovery" class="gap-stack grid">
|
||||
<Label for="code">{{ t('auth.twoFactor.code') }}</Label>
|
||||
<Input
|
||||
id="code"
|
||||
key="code"
|
||||
ref="codeInput"
|
||||
v-model="form.code"
|
||||
name="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
minlength="6"
|
||||
maxlength="6"
|
||||
required
|
||||
autofocus
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(fieldError)"
|
||||
aria-describedby="code-hint code-error"
|
||||
class="h-14 text-center font-mono text-2xl tracking-[0.35em] md:text-2xl"
|
||||
@paste="pasteCode"
|
||||
/>
|
||||
<p id="code-hint" class="text-caption text-muted-foreground">
|
||||
{{ t('auth.twoFactor.codeHint') }}
|
||||
</p>
|
||||
<InputError id="code-error" :message="fieldError" />
|
||||
</div>
|
||||
<div v-else class="gap-stack grid">
|
||||
<Label for="recovery_code">{{
|
||||
t('auth.twoFactor.recoveryCode')
|
||||
}}</Label>
|
||||
<Input
|
||||
id="recovery_code"
|
||||
key="recovery"
|
||||
ref="recoveryInput"
|
||||
v-model="form.recovery_code"
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
autocapitalize="none"
|
||||
:spellcheck="false"
|
||||
required
|
||||
:disabled="form.processing"
|
||||
:invalid="Boolean(fieldError)"
|
||||
aria-describedby="recovery-hint recovery-error"
|
||||
class="font-mono"
|
||||
/>
|
||||
<p
|
||||
id="recovery-hint"
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
<input type="hidden" name="code" :value="code" />
|
||||
<div
|
||||
class="flex flex-col items-center justify-center space-y-3 text-center"
|
||||
>
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<InputOTP
|
||||
id="otp"
|
||||
v-model="code"
|
||||
:maxlength="6"
|
||||
:disabled="processing"
|
||||
autofocus
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
v-for="index in 6"
|
||||
:key="index"
|
||||
:index="index - 1"
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<InputError :message="errors.code" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Form
|
||||
v-bind="store.form()"
|
||||
class="space-y-4"
|
||||
reset-on-error
|
||||
#default="{ errors, processing, clearErrors }"
|
||||
>
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
:autofocus="showRecoveryInput"
|
||||
required
|
||||
/>
|
||||
<InputError :message="errors.recovery_code" />
|
||||
<Button type="submit" class="w-full" :disabled="processing"
|
||||
>Continue</Button
|
||||
>
|
||||
|
||||
<div class="text-muted-foreground text-center text-sm">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-foreground hover:decoration-current! underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out dark:decoration-neutral-500"
|
||||
@click="() => toggleRecoveryMode(clearErrors)"
|
||||
>
|
||||
{{ authConfigContent.buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
{{ t('auth.twoFactor.recoveryHint') }}
|
||||
</p>
|
||||
<InputError id="recovery-error" :message="fieldError" />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:loading="form.processing"
|
||||
:loading-label="t('auth.twoFactor.pending')"
|
||||
>{{ t('auth.twoFactor.submit') }}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full whitespace-normal"
|
||||
:disabled="form.processing"
|
||||
@click="toggleMode"
|
||||
>{{
|
||||
t(
|
||||
recovery
|
||||
? 'auth.twoFactor.useCode'
|
||||
: 'auth.twoFactor.useRecovery',
|
||||
)
|
||||
}}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer
|
||||
><TextLink :href="login()">{{
|
||||
t('auth.backToLogin')
|
||||
}}</TextLink></template
|
||||
>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,49 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { Form, Head } from '@inertiajs/vue3';
|
||||
import TextLink from '@/components/TextLink.vue';
|
||||
import { Head, router, useForm, usePage } from '@inertiajs/vue3';
|
||||
import { Mail } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import { logout } from '@/routes/index';
|
||||
import { useFormProtection } from '@/composables/useFormProtection';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AuthFlowLayout from '@/layouts/auth/AuthFlowLayout.vue';
|
||||
import { dashboard, logout } from '@/routes/index';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
defineProps<{
|
||||
status?: string;
|
||||
}>();
|
||||
const props = defineProps<{ status?: string }>();
|
||||
const { t } = useLocalization();
|
||||
const page = usePage();
|
||||
const email = computed(() => page.props.auth.user?.email);
|
||||
const resendForm = useForm({});
|
||||
const logoutForm = useForm({});
|
||||
const resendProtection = useFormProtection(resendForm);
|
||||
const logoutProtection = useFormProtection(logoutForm);
|
||||
const busy = computed(() => resendForm.processing || logoutForm.processing);
|
||||
const sent = computed(
|
||||
() =>
|
||||
resendProtection.state.value === 'success' ||
|
||||
(resendProtection.state.value === 'idle' &&
|
||||
props.status === 'verification-link-sent'),
|
||||
);
|
||||
const resend = (): void => {
|
||||
if (!busy.value) {
|
||||
resendProtection.submit(send(), { preserveScroll: true });
|
||||
}
|
||||
};
|
||||
const signOut = (): void => {
|
||||
if (!busy.value) {
|
||||
logoutProtection.submit(logout());
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLayout
|
||||
title="Verify email"
|
||||
description="Please verify your email address by clicking on the link we just emailed to you."
|
||||
<AuthFlowLayout
|
||||
:title="t('auth.verify.title')"
|
||||
:description="t('auth.verify.description')"
|
||||
:icon="Mail"
|
||||
>
|
||||
<Head title="Email verification" />
|
||||
|
||||
<Head :title="t('auth.verify.title')" />
|
||||
<div
|
||||
v-if="status === 'verification-link-sent'"
|
||||
class="mb-4 text-center text-sm font-medium text-green-600"
|
||||
v-if="email"
|
||||
class="gap-stack bg-muted p-card grid rounded-md border"
|
||||
>
|
||||
A new verification link has been sent to the email address you
|
||||
provided during registration.
|
||||
<p class="text-label text-muted-foreground uppercase tracking-wide">
|
||||
{{ t('auth.verify.emailLabel') }}
|
||||
</p>
|
||||
<p class="text-body break-all font-medium">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
v-bind="send.form()"
|
||||
class="space-y-6 text-center"
|
||||
v-slot="{ processing }"
|
||||
<FeedbackState
|
||||
v-if="sent"
|
||||
state="success"
|
||||
:title="t('auth.verify.sentTitle')"
|
||||
:description="t('auth.verify.sentDescription')"
|
||||
/>
|
||||
<form
|
||||
:action="send.url()"
|
||||
method="post"
|
||||
class="gap-column grid"
|
||||
@submit.prevent="resend"
|
||||
>
|
||||
<Button :disabled="processing" variant="secondary">
|
||||
<Spinner v-if="processing" />
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<TextLink
|
||||
:href="logout()"
|
||||
as="button"
|
||||
class="mx-auto block text-sm"
|
||||
<AuthFormFeedback
|
||||
:state="
|
||||
resendProtection.state.value === 'success'
|
||||
? 'idle'
|
||||
: resendProtection.state.value
|
||||
"
|
||||
:response-status="resendProtection.responseStatus.value"
|
||||
:loading-title="t('auth.verify.pending')"
|
||||
@retry="resend"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:disabled="busy"
|
||||
:loading="resendForm.processing"
|
||||
:loading-label="t('auth.verify.pending')"
|
||||
>{{ t('auth.verify.resend') }}</Button
|
||||
>
|
||||
Log out
|
||||
</TextLink>
|
||||
</Form>
|
||||
</AuthLayout>
|
||||
</form>
|
||||
<div class="gap-stack pt-column grid border-t">
|
||||
<p class="text-body text-muted-foreground">
|
||||
{{ t('auth.verify.help') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="busy"
|
||||
@click="router.visit(dashboard())"
|
||||
>{{ t('auth.verify.continue') }}</Button
|
||||
>
|
||||
</div>
|
||||
<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="ghost"
|
||||
class="w-full"
|
||||
:disabled="busy"
|
||||
:loading="logoutForm.processing"
|
||||
:loading-label="t('auth.signingOut')"
|
||||
>{{ t('auth.signOut') }}</Button
|
||||
>
|
||||
</form>
|
||||
<template #footer>{{ t('auth.verify.wrongAccount') }}</template>
|
||||
</AuthFlowLayout>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Features;
|
||||
@@ -85,7 +86,7 @@ class AuthenticationTest extends TestCase
|
||||
$this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
])->assertSessionHasErrors('email');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
@@ -100,6 +101,22 @@ class AuthenticationTest extends TestCase
|
||||
$response->assertRedirect(route('home'));
|
||||
}
|
||||
|
||||
public function test_login_preserves_the_intended_destination_and_remember_choice(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->withSession(['url.intended' => route('dashboard')])
|
||||
->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'remember' => true,
|
||||
])
|
||||
->assertRedirect(route('dashboard'))
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_users_are_rate_limited()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -61,7 +61,7 @@ class EmailVerificationTest extends TestCase
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
@@ -79,7 +79,7 @@ class EmailVerificationTest extends TestCase
|
||||
['id' => 123, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
@@ -97,6 +97,34 @@ class EmailVerificationTest extends TestCase
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_the_ui_rework_preserves_the_existing_optional_verification_policy(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$this->actingAs($user)->get(route('dashboard'))->assertOk();
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
|
||||
public function test_guests_cannot_view_the_verification_prompt(): void
|
||||
{
|
||||
$this->get(route('verification.notice'))->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_expired_verification_links_cannot_verify_an_email(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->subMinute(),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)],
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
|
||||
public function test_already_verified_user_visiting_verification_link_is_redirected_without_firing_event_again(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||
use Laravel\Fortify\Features;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -52,4 +54,77 @@ class TwoFactorChallengeTest extends TestCase
|
||||
->component('auth/TwoFactorChallenge'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_valid_authenticator_code_completes_the_pending_login(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
||||
->shouldReceive('verify')->once()->with('secret', '012345')->andReturnTrue();
|
||||
|
||||
$this->withSession(['url.intended' => route('dashboard')])
|
||||
->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'remember' => true,
|
||||
])->assertRedirect(route('two-factor.login'));
|
||||
$this->assertGuest();
|
||||
|
||||
$this->post(route('two-factor.login.store'), ['code' => '012345'])
|
||||
->assertRedirect(route('dashboard'))
|
||||
->assertSessionMissing('login.id')
|
||||
->assertSessionMissing('login.remember')
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
}
|
||||
|
||||
public function test_invalid_authenticator_code_keeps_the_pending_login_for_retry(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
$this->mock(TwoFactorAuthenticationProvider::class)
|
||||
->shouldReceive('verify')->once()->with('secret', '000000')->andReturnFalse();
|
||||
|
||||
$this->withSession(['login.id' => $user->id])
|
||||
->from(route('two-factor.login'))
|
||||
->post(route('two-factor.login.store'), ['code' => '000000'])
|
||||
->assertRedirect(route('two-factor.login'))
|
||||
->assertSessionHasErrors('code')
|
||||
->assertSessionHas('login.id', $user->id);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_recovery_code_signs_in_once_and_is_replaced(): void
|
||||
{
|
||||
$user = User::factory()->withTwoFactor()->create();
|
||||
|
||||
$this->withSession(['login.id' => $user->id, 'login.remember' => true])
|
||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
||||
->assertRedirect(route('dashboard', absolute: false))
|
||||
->assertSessionMissing('login.id')
|
||||
->assertCookie(Auth::guard('web')->getRecallerName());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
$this->assertNotContains('recovery-code-1', $user->fresh()->recoveryCodes());
|
||||
$this->assertCount(1, $user->fresh()->recoveryCodes());
|
||||
|
||||
$this->post(route('logout'));
|
||||
$this->withSession(['login.id' => $user->id])
|
||||
->post(route('two-factor.login.store'), ['recovery_code' => 'recovery-code-1'])
|
||||
->assertSessionHasErrors('recovery_code')
|
||||
->assertSessionHas('login.id', $user->id);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_challenge_submission_cannot_sign_in_without_a_pending_login(): void
|
||||
{
|
||||
$this->from(route('two-factor.login'))
|
||||
->post(route('two-factor.login.store'), ['code' => '012345'])
|
||||
->assertRedirect(route('two-factor.login'))
|
||||
->assertSessionHasErrors('code');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->get(route('two-factor.login'))->assertRedirect(route('login'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ class VerificationNotificationTest extends TestCase
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('verification.send'))
|
||||
->assertRedirect(route('home'));
|
||||
->assertRedirect(route('home'))
|
||||
->assertSessionHas('status', 'verification-link-sent');
|
||||
|
||||
Notification::assertSentTo($user, VerifyEmail::class);
|
||||
}
|
||||
@@ -45,4 +46,27 @@ class VerificationNotificationTest extends TestCase
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_guests_cannot_request_a_verification_email(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$this->post(route('verification.send'))->assertRedirect(route('login'));
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_resending_verification_emails_is_throttled(): void
|
||||
{
|
||||
Notification::fake();
|
||||
$user = User::factory()->unverified()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
for ($attempt = 0; $attempt < 6; $attempt++) {
|
||||
$this->post(route('verification.send'))->assertRedirect();
|
||||
}
|
||||
|
||||
$this->post(route('verification.send'))->assertTooManyRequests();
|
||||
Notification::assertSentToTimes($user, VerifyEmail::class, 6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
|
||||
const { chromium } = await import(
|
||||
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
||||
);
|
||||
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
||||
const base = 'http://127.0.0.1:4177';
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const errors = [];
|
||||
const submissions = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST') {
|
||||
submissions.push({
|
||||
path: new URL(request.url()).pathname,
|
||||
data: request.postDataJSON(),
|
||||
});
|
||||
}
|
||||
});
|
||||
const outcome = (value) =>
|
||||
context.addCookies([{ name: 'auth_test_result', value, url: base }]);
|
||||
const open = async (path) => {
|
||||
await page.goto(base + path);
|
||||
await page.locator('main h1').waitFor();
|
||||
};
|
||||
const state = (value) =>
|
||||
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
||||
const credentials = async () => {
|
||||
await page
|
||||
.getByLabel('Email address', { exact: true })
|
||||
.fill('alex@example.test');
|
||||
await page
|
||||
.getByLabel('Password', { exact: true })
|
||||
.fill('fixture-private-password');
|
||||
};
|
||||
const noStoredSecrets = async () => {
|
||||
const persisted = await page.evaluate(() =>
|
||||
JSON.stringify({
|
||||
local: { ...localStorage },
|
||||
session: { ...sessionStorage },
|
||||
history: history.state,
|
||||
}),
|
||||
);
|
||||
for (const secret of [
|
||||
'fixture-private-password',
|
||||
'012345',
|
||||
'fixture-recovery-code',
|
||||
]) {
|
||||
assert.ok(
|
||||
!persisted.includes(secret),
|
||||
'Credentials must not be remembered in browser storage',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (const width of [320, 390, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
for (const path of [
|
||||
'/login',
|
||||
'/email/verify',
|
||||
'/two-factor-challenge',
|
||||
]) {
|
||||
await open(path);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: theme === 'light' ? 'Light' : 'Dark',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: theme === 'light' ? 'Light' : 'Dark',
|
||||
exact: true,
|
||||
pressed: true,
|
||||
})
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
`${path} must fit at ${width}`,
|
||||
);
|
||||
const controls = await page
|
||||
.locator(
|
||||
'main button:not([role="checkbox"]), main label[for="remember"], main input:not([type="checkbox"]):not([aria-hidden="true"])',
|
||||
)
|
||||
.evaluateAll((els) =>
|
||||
els
|
||||
.filter(
|
||||
(el) => el.getBoundingClientRect().width > 0,
|
||||
)
|
||||
.map((el) => el.getBoundingClientRect().height),
|
||||
);
|
||||
assert.ok(
|
||||
controls.every((height) => height >= 44),
|
||||
`${path} has touch-sized controls`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan21-${path.split('/').at(-1)}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 480 });
|
||||
await open('/login');
|
||||
await page.getByRole('button', { name: 'System', exact: true }).click();
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.waitForFunction(
|
||||
() => !document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.waitForFunction(() =>
|
||||
document.documentElement.classList.contains('dark'),
|
||||
);
|
||||
await credentials();
|
||||
const password = page.locator('#password');
|
||||
assert.equal(
|
||||
await password.getAttribute('autocomplete'),
|
||||
'current-password',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#email').getAttribute('autocomplete'),
|
||||
'username',
|
||||
);
|
||||
await password.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await page
|
||||
.getByRole('button', { name: 'Show password' })
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(await password.getAttribute('type'), 'text');
|
||||
await page.getByRole('button', { name: 'Hide password' }).click();
|
||||
await page.getByRole('checkbox', { name: 'Remember me' }).check();
|
||||
await outcome('validation');
|
||||
await password.press('Enter');
|
||||
await state('validation');
|
||||
assert.equal(await password.inputValue(), 'fixture-private-password');
|
||||
assert.equal(
|
||||
await page.locator('#email').inputValue(),
|
||||
'alex@example.test',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#email').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(submissions.at(-1).data.remember, true);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('[data-form-state="validation"]')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
await noStoredSecrets();
|
||||
|
||||
await page.route('**/login', (route) =>
|
||||
route.request().method() === 'POST'
|
||||
? route.abort('failed')
|
||||
: route.continue(),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await state('network');
|
||||
assert.equal(await password.inputValue(), 'fixture-private-password');
|
||||
await page.unroute('**/login');
|
||||
await outcome('limited');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
||||
await outcome('expired');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page.getByText('Your session has expired', { exact: true }).waitFor();
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'Try again' }).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole('link', { name: 'Return to sign in' }).click();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#password')?.value === '',
|
||||
);
|
||||
|
||||
await outcome('success');
|
||||
await credentials();
|
||||
let release;
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/login', async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
const before = submissions.length;
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await state('loading');
|
||||
assert.equal(await password.isDisabled(), true);
|
||||
assert.equal(
|
||||
await page.getByRole('button', { name: 'Show password' }).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await page
|
||||
.locator('form')
|
||||
.evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(submissions.length, before + 1);
|
||||
release();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await page.unroute('**/login');
|
||||
await noStoredSecrets();
|
||||
await page.goBack();
|
||||
await page.locator('#password').waitFor();
|
||||
assert.equal(await password.inputValue(), '');
|
||||
|
||||
await open('/login?no-reset=1');
|
||||
assert.equal(
|
||||
await page.getByRole('link', { name: 'Forgot password?' }).count(),
|
||||
0,
|
||||
);
|
||||
await credentials();
|
||||
await outcome('challenge');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.locator('#code').waitFor();
|
||||
assert.equal(
|
||||
await page.locator('#code').getAttribute('inputmode'),
|
||||
'numeric',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#code').getAttribute('autocomplete'),
|
||||
'one-time-code',
|
||||
);
|
||||
await page.locator('#code').evaluate((el) => {
|
||||
const clipboardData = new DataTransfer();
|
||||
clipboardData.setData('text', '012 345');
|
||||
el.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.equal(await page.locator('#code').inputValue(), '012345');
|
||||
await outcome('validation');
|
||||
await page.locator('#code').press('Enter');
|
||||
await state('validation');
|
||||
assert.deepEqual(submissions.at(-1).data, { code: '012345' });
|
||||
assert.equal(await page.locator('#code').inputValue(), '012345');
|
||||
await page
|
||||
.getByRole('button', { name: 'Use a recovery code instead' })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#recovery_code')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
||||
await page.locator('#recovery_code').press('Enter');
|
||||
await state('validation');
|
||||
assert.deepEqual(submissions.at(-1).data, {
|
||||
recovery_code: 'fixture-recovery-code',
|
||||
});
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').getAttribute('aria-invalid'),
|
||||
'true',
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').inputValue(),
|
||||
'fixture-recovery-code',
|
||||
);
|
||||
await noStoredSecrets();
|
||||
await page
|
||||
.getByRole('button', { name: 'Use an authenticator code instead' })
|
||||
.click();
|
||||
assert.equal(await page.locator('#code').inputValue(), '');
|
||||
await page
|
||||
.getByRole('button', { name: 'Use a recovery code instead' })
|
||||
.click();
|
||||
assert.equal(await page.locator('#recovery_code').inputValue(), '');
|
||||
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
||||
await outcome('server');
|
||||
await page.getByRole('button', { name: 'Verify and sign in' }).click();
|
||||
await state('server');
|
||||
assert.equal(
|
||||
await page.locator('#recovery_code').inputValue(),
|
||||
'fixture-recovery-code',
|
||||
);
|
||||
await outcome('success');
|
||||
await page.getByRole('button', { name: 'Try again' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await noStoredSecrets();
|
||||
|
||||
await open('/email/verify');
|
||||
await page.getByText('alex.morgan@example.test', { exact: true }).waitFor();
|
||||
await page
|
||||
.getByRole('button', { name: 'Resend verification email', exact: true })
|
||||
.click();
|
||||
await page.getByText('Verification link sent', { exact: true }).waitFor();
|
||||
assert.equal(submissions.at(-1).path, '/email/verification-notification');
|
||||
await outcome('limited');
|
||||
await page
|
||||
.getByRole('button', { name: 'Resend verification email', exact: true })
|
||||
.click();
|
||||
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
||||
await outcome('success');
|
||||
await page.getByRole('button', { name: 'Continue to workspace' }).click();
|
||||
await page
|
||||
.getByRole('heading', { name: 'Signed in destination' })
|
||||
.waitFor();
|
||||
await open('/email/verify');
|
||||
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
|
||||
await page.locator('#email').waitFor();
|
||||
assert.equal(submissions.at(-1).path, '/logout');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Auth browser checks passed: themes, responsive layout, keyboard, autofill, retention, retry, expiry, throttling, 2FA, recovery, verification and logout.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp, h } from 'vue';
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import '../../resources/css/app.css';
|
||||
import Login from '../../resources/js/pages/auth/Login.vue';
|
||||
import VerifyEmail from '../../resources/js/pages/auth/VerifyEmail.vue';
|
||||
import TwoFactorChallenge from '../../resources/js/pages/auth/TwoFactorChallenge.vue';
|
||||
import { createAppearance } from '../../resources/js/composables/useAppearance';
|
||||
import { createLocalization } from '../../resources/js/lib/localization';
|
||||
|
||||
const Destination = { render: () => h('h1', 'Signed in destination') };
|
||||
createInertiaApp({
|
||||
resolve: (name) =>
|
||||
({ Login, VerifyEmail, TwoFactorChallenge, Destination })[name],
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.use(createLocalization())
|
||||
.use(createAppearance())
|
||||
.mount(el),
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
// Isolated Inertia transport fixture; real Fortify sessions are covered by PHPUnit.
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite/auth-fixture',
|
||||
plugins: [vue(), tailwind()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: { host: '127.0.0.1', port: 4177, strictPort: true, hmr: false },
|
||||
appType: 'custom',
|
||||
});
|
||||
|
||||
server.middlewares.use(async (request, response, next) => {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
const pages = {
|
||||
'/login': 'Login',
|
||||
'/two-factor-challenge': 'TwoFactorChallenge',
|
||||
'/email/verify': 'VerifyEmail',
|
||||
'/email/verification-notification': 'VerifyEmail',
|
||||
'/dashboard': 'Destination',
|
||||
'/logout': 'Login',
|
||||
'/forgot-password': 'Destination',
|
||||
'/': 'Destination',
|
||||
};
|
||||
if (!(url.pathname in pages)) {
|
||||
return next();
|
||||
}
|
||||
const mutation = request.method === 'POST';
|
||||
const result =
|
||||
request.headers.cookie?.match(/auth_test_result=(\w+)/)?.[1] ??
|
||||
'success';
|
||||
if (mutation && ['server', 'expired', 'limited'].includes(result)) {
|
||||
response.statusCode = { server: 503, expired: 419, limited: 429 }[
|
||||
result
|
||||
];
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end('<h1>Simulated failed response</h1>');
|
||||
return;
|
||||
}
|
||||
let body = '';
|
||||
if (mutation) {
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
}
|
||||
const submitted = body ? JSON.parse(body) : {};
|
||||
let path = url.pathname;
|
||||
let errors = {};
|
||||
let status;
|
||||
if (mutation && result === 'validation') {
|
||||
path = new URL(request.headers.referer).pathname;
|
||||
const field =
|
||||
path === '/login'
|
||||
? 'email'
|
||||
: 'recovery_code' in submitted
|
||||
? 'recovery_code'
|
||||
: 'code';
|
||||
errors = { [field]: 'These details could not be verified.' };
|
||||
} else if (mutation) {
|
||||
if (path === '/email/verification-notification') {
|
||||
path = '/email/verify';
|
||||
status = 'verification-link-sent';
|
||||
} else if (path === '/logout') {
|
||||
path = '/login';
|
||||
} else {
|
||||
path =
|
||||
result === 'challenge' ? '/two-factor-challenge' : '/dashboard';
|
||||
}
|
||||
}
|
||||
const page = {
|
||||
component: pages[path],
|
||||
url: path,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors,
|
||||
status,
|
||||
canResetPassword: !url.searchParams.has('no-reset'),
|
||||
auth: {
|
||||
user:
|
||||
path === '/email/verify'
|
||||
? {
|
||||
id: 1,
|
||||
name: 'Alex Morgan',
|
||||
email: 'alex.morgan@example.test',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
response.setHeader('Vary', 'X-Inertia');
|
||||
if (request.headers['x-inertia']) {
|
||||
response.setHeader('X-Inertia', 'true');
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(page));
|
||||
return;
|
||||
}
|
||||
const escaped = JSON.stringify(page)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
const html =
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/auth-preview.mjs"></script></body></html>';
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(await server.transformIndexHtml(request.url, html));
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Auth fixture ready at http://127.0.0.1:4177/login');
|
||||
@@ -69,6 +69,12 @@ try {
|
||||
const { default: InputError } = await server.ssrLoadModule(
|
||||
'/resources/js/components/InputError.vue',
|
||||
);
|
||||
const { default: AuthFormFeedback } = await server.ssrLoadModule(
|
||||
'/resources/js/components/AuthFormFeedback.vue',
|
||||
);
|
||||
const { default: PasswordInput } = await server.ssrLoadModule(
|
||||
'/resources/js/components/PasswordInput.vue',
|
||||
);
|
||||
const localization = createLocalization(
|
||||
{ locale: 'de' },
|
||||
{
|
||||
@@ -76,6 +82,8 @@ try {
|
||||
...en,
|
||||
'common.retry': 'Erneut versuchen',
|
||||
'feedback.empty': 'Noch keine Einträge',
|
||||
'auth.feedback.expiredTitle': 'Sitzung abgelaufen',
|
||||
'auth.password.show': 'Passwort anzeigen',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -123,6 +131,40 @@ try {
|
||||
assert.match(fieldError, /role="alert"/);
|
||||
assert.match(fieldError, /text-destructive/);
|
||||
assert.doesNotMatch(fieldError, /<script>/);
|
||||
const expired = await render(AuthFormFeedback, {
|
||||
state: 'server',
|
||||
responseStatus: 419,
|
||||
});
|
||||
assert.match(expired, /Sitzung abgelaufen/);
|
||||
assert.match(expired, /href="\/login"/);
|
||||
assert.doesNotMatch(expired, /Erneut versuchen/);
|
||||
assert.match(
|
||||
await render(AuthFormFeedback, {
|
||||
state: 'server',
|
||||
responseStatus: 429,
|
||||
}),
|
||||
/Too many attempts/,
|
||||
);
|
||||
assert.match(
|
||||
await render(AuthFormFeedback, {
|
||||
state: 'loading',
|
||||
loadingTitle: 'Signing in',
|
||||
}),
|
||||
/Signing in/,
|
||||
);
|
||||
assert.match(
|
||||
await render(AuthFormFeedback, { state: 'validation' }),
|
||||
/Check your details/,
|
||||
);
|
||||
const password = await render(PasswordInput, {
|
||||
disabled: true,
|
||||
autocomplete: 'current-password',
|
||||
});
|
||||
assert.match(password, /Passwort anzeigen/);
|
||||
assert.match(password, /autocomplete="current-password"/);
|
||||
assert.match(password, /aria-pressed="false"/);
|
||||
assert.doesNotMatch(password, /tabindex="-1"/);
|
||||
assert.match(password, /<button[^>]*disabled/);
|
||||
console.log('Form feedback behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
|
||||
Reference in New Issue
Block a user