Files
DredgeTours/resources/js/components/TwoFactorSetupModal.vue
2025-09-13 22:32:19 +10:00

189 lines
8.3 KiB
Vue

<script setup lang="ts">
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { PinInput, PinInputGroup, PinInputSlot } from '@/components/ui/pin-input';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { Check, Copy, Loader2, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
interface Props {
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
}
const props = defineProps<Props>();
const isOpen = defineModel<boolean>('isOpen');
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData } = useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<number[]>([]);
const codeValue = computed<string>(() => code.value.join(''));
const pinInputContainerRef = ref<HTMLElement | null>(null);
const modalConfig = computed<{ title: string; description: string; buttonText: string }>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-Factor Authentication Enabled',
description: 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
}
if (showVerificationStep.value) {
return {
title: 'Verify Authentication Code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable Two-Factor Authentication',
description: 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
}
showVerificationStep.value = false;
code.value = [];
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
}
},
);
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm">
<div class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
<div class="absolute inset-0 grid grid-cols-5 opacity-50">
<div v-for="i in 5" :key="`col-${i}`" class="border-r border-border last:border-r-0" />
</div>
<div class="absolute inset-0 grid grid-rows-5 opacity-50">
<div v-for="i in 5" :key="`row-${i}`" class="border-b border-border last:border-b-0" />
</div>
<ScanLine class="relative z-20 size-6 text-foreground" />
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div class="relative flex w-auto flex-col items-center justify-center space-y-5">
<template v-if="!showVerificationStep">
<div class="relative mx-auto flex max-w-md items-center overflow-hidden">
<div class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border">
<div
v-if="!qrCodeSvg"
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
>
<Loader2 class="size-6 animate-spin" />
</div>
<div v-else class="relative z-10 overflow-hidden border p-5">
<div v-html="qrCodeSvg" class="flex aspect-square size-full items-center justify-center" />
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div class="relative flex w-full items-center justify-center">
<div class="absolute inset-0 top-1/2 h-px w-full bg-border" />
<span class="relative bg-card px-2 py-1">or, enter the code manually</span>
</div>
<div class="flex w-full items-center justify-center space-x-2">
<div class="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
<div v-if="!manualSetupKey" class="flex h-full w-full items-center justify-center bg-muted p-3">
<Loader2 class="size-4 animate-spin" />
</div>
<template v-else>
<input type="text" readonly :value="manualSetupKey" class="h-full w-full bg-background p-3 text-foreground" />
<button @click="copy(manualSetupKey || '')" class="relative block h-auto border-l border-border px-3 hover:bg-muted">
<Check v-if="copied" class="w-4 text-green-500" />
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
<template v-else>
<Form v-bind="confirm.form()" reset-on-error @finish="code = []" @success="isOpen = false" v-slot="{ errors, processing }">
<input type="hidden" name="code" :value="codeValue" />
<div ref="pinInputContainerRef" class="relative w-full space-y-3">
<div class="flex w-full flex-col items-center justify-center space-y-3 py-2">
<PinInput id="otp" placeholder="○" v-model="code" type="number" otp>
<PinInputGroup>
<PinInputSlot autofocus v-for="(id, index) in 6" :key="id" :index="index" :disabled="processing" />
</PinInputGroup>
</PinInput>
<InputError :message="errors?.confirmTwoFactorAuthentication?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button type="submit" class="w-auto flex-1" :disabled="processing || codeValue.length < 6"> Confirm </Button>
</div>
</div>
</Form>
</template>
</div>
</DialogContent>
</Dialog>
</template>