feat(KAN-21): rebuild login verification and two-factor flows
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user