262 lines
9.2 KiB
Vue
262 lines
9.2 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Check, Copy, ScanLine } from 'lucide-vue-next';
|
|
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
|
import AlertError from '@/components/AlertError.vue';
|
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
import InputError from '@/components/InputError.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
|
import { confirm } from '@/routes/two-factor';
|
|
|
|
const props = defineProps<{
|
|
requiresConfirmation: boolean;
|
|
twoFactorEnabled: boolean;
|
|
}>();
|
|
const isOpen = defineModel<boolean>('isOpen');
|
|
const { t } = useLocalization();
|
|
const {
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
hasSetupData,
|
|
clearSetupData,
|
|
fetchSetupData,
|
|
errors,
|
|
loading,
|
|
} = useTwoFactorAuth();
|
|
const form = useForm({ code: '' }).dontRemember('code');
|
|
const protection = useFormProtection(form, { isDirty: () => false });
|
|
const verification = ref(false);
|
|
const copied = ref(false);
|
|
const copyError = ref(false);
|
|
const codeInput = useTemplateRef('codeInput');
|
|
const title = computed(() =>
|
|
t(
|
|
props.twoFactorEnabled
|
|
? 'security.setupEnabled'
|
|
: verification.value
|
|
? 'security.verifyTitle'
|
|
: 'security.setupTitle',
|
|
),
|
|
);
|
|
const description = computed(() =>
|
|
t(
|
|
props.twoFactorEnabled
|
|
? 'security.setupEnabledDescription'
|
|
: verification.value
|
|
? 'security.verifyDescription'
|
|
: 'security.setupDescription',
|
|
),
|
|
);
|
|
|
|
function close(open: boolean): void {
|
|
if (!form.processing) {
|
|
isOpen.value = open;
|
|
}
|
|
}
|
|
async function next(): Promise<void> {
|
|
if (props.twoFactorEnabled || !props.requiresConfirmation) {
|
|
close(false);
|
|
return;
|
|
}
|
|
verification.value = true;
|
|
await nextTick();
|
|
codeInput.value?.$el?.focus();
|
|
}
|
|
function submit(): void {
|
|
protection.submit(confirm(), {
|
|
preserveScroll: true,
|
|
errorBag: 'confirmTwoFactorAuthentication',
|
|
onSuccess: () => {
|
|
isOpen.value = false;
|
|
},
|
|
onFinish: () => {
|
|
form.reset();
|
|
},
|
|
});
|
|
}
|
|
async function copyKey(): Promise<void> {
|
|
const key = manualSetupKey.value;
|
|
if (!key) {
|
|
return;
|
|
}
|
|
copyError.value = false;
|
|
try {
|
|
await navigator.clipboard.writeText(key);
|
|
if (isOpen.value && manualSetupKey.value === key) {
|
|
copied.value = true;
|
|
}
|
|
} catch {
|
|
if (isOpen.value) {
|
|
copyError.value = true;
|
|
}
|
|
}
|
|
}
|
|
watch(isOpen, (open) => {
|
|
verification.value = false;
|
|
copied.value = false;
|
|
copyError.value = false;
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
clearSetupData();
|
|
if (open) {
|
|
void fetchSetupData();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog :open="isOpen" @update:open="close">
|
|
<DialogContent
|
|
class="sm:max-w-md"
|
|
:show-close-button="!form.processing"
|
|
@escape-key-down="form.processing && $event.preventDefault()"
|
|
@interact-outside="form.processing && $event.preventDefault()"
|
|
>
|
|
<DialogHeader>
|
|
<ScanLine class="text-primary size-8" />
|
|
<DialogTitle>{{ title }}</DialogTitle>
|
|
<DialogDescription>{{ description }}</DialogDescription>
|
|
</DialogHeader>
|
|
<template v-if="!verification">
|
|
<p
|
|
v-if="loading"
|
|
role="status"
|
|
class="bg-muted animate-pulse rounded-lg p-6 text-center"
|
|
>
|
|
{{ t('security.loadingSetup') }}
|
|
</p>
|
|
<div v-else-if="errors.length" class="gap-stack grid">
|
|
<AlertError :errors="errors" />
|
|
<Button variant="outline" @click="fetchSetupData">{{
|
|
t('common.retry')
|
|
}}</Button>
|
|
</div>
|
|
<div v-else-if="hasSetupData" class="gap-column grid min-w-0">
|
|
<div
|
|
class="mx-auto aspect-square w-full max-w-56 rounded-lg border bg-white p-4 text-black"
|
|
v-html="qrCodeSvg"
|
|
/>
|
|
<div class="grid min-w-0 gap-2">
|
|
<Label for="setup-key">{{
|
|
t('security.manualKey')
|
|
}}</Label>
|
|
<div class="flex min-w-0 gap-2">
|
|
<Input
|
|
id="setup-key"
|
|
:model-value="manualSetupKey"
|
|
readonly
|
|
class="min-w-0 font-mono text-xs"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
:aria-label="
|
|
t(
|
|
copied
|
|
? 'security.copied'
|
|
: 'security.copyKey',
|
|
)
|
|
"
|
|
@click="copyKey"
|
|
><Check v-if="copied" /><Copy v-else
|
|
/></Button>
|
|
</div>
|
|
<p
|
|
v-if="copied"
|
|
role="status"
|
|
class="text-caption text-success-foreground"
|
|
>
|
|
{{ t('security.copied') }}
|
|
</p>
|
|
<InputError
|
|
v-if="copyError"
|
|
:message="t('security.copyError')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p
|
|
v-if="requiresConfirmation && !twoFactorEnabled"
|
|
class="text-caption text-muted-foreground"
|
|
>
|
|
{{ t('security.setupPendingDescription') }}
|
|
</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" @click="close(false)">{{
|
|
t('common.close')
|
|
}}</Button>
|
|
<Button v-if="hasSetupData" @click="next">{{
|
|
t(
|
|
twoFactorEnabled || !requiresConfirmation
|
|
? 'common.done'
|
|
: 'security.continue',
|
|
)
|
|
}}</Button>
|
|
</DialogFooter>
|
|
</template>
|
|
<form v-else class="gap-column grid" @submit.prevent="submit">
|
|
<AuthFormFeedback
|
|
:state="protection.state.value"
|
|
:response-status="protection.responseStatus.value"
|
|
@retry="submit"
|
|
/>
|
|
<div class="grid gap-2">
|
|
<Label for="authentication-code">{{
|
|
t('security.authenticationCode')
|
|
}}</Label>
|
|
<Input
|
|
id="authentication-code"
|
|
ref="codeInput"
|
|
v-model="form.code"
|
|
name="code"
|
|
type="text"
|
|
inputmode="numeric"
|
|
autocomplete="one-time-code"
|
|
pattern="[0-9]{6}"
|
|
maxlength="6"
|
|
required
|
|
:disabled="form.processing"
|
|
:aria-invalid="!!form.errors.code"
|
|
aria-describedby="authentication-code-error"
|
|
class="font-mono text-lg tracking-widest"
|
|
/>
|
|
<InputError
|
|
id="authentication-code-error"
|
|
:message="form.errors.code"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:disabled="form.processing"
|
|
@click="
|
|
verification = false;
|
|
form.resetAndClearErrors();
|
|
protection.clearFeedback();
|
|
"
|
|
>{{ t('common.back') }}</Button
|
|
>
|
|
<Button
|
|
type="submit"
|
|
:disabled="form.code.length !== 6"
|
|
:loading="form.processing"
|
|
>{{ t('security.confirm') }}</Button
|
|
>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|