feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-11 14:18:23 +02:00
parent 5e52e4fe78
commit 802294d6a2
17 changed files with 1294 additions and 250 deletions
@@ -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>
+15 -6
View File
@@ -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')"
/>
+11 -4
View File
@@ -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>
+63
View File
@@ -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',
+105 -81
View File
@@ -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>
+159 -119
View File
@@ -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>
+110 -35
View File
@@ -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>