Skip to content

Commit 08b9471

Browse files
committed
refactor: split the component
1 parent 9b81898 commit 08b9471

File tree

4 files changed

+333
-329
lines changed

4 files changed

+333
-329
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script setup lang="ts">
2+
import { Button } from '@/components/ui/button';
3+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4+
import { Form } from '@inertiajs/vue3';
5+
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
6+
import { nextTick, onMounted, ref } from 'vue';
7+
8+
const recoveryCodesList = ref<string[]>([]);
9+
const isRecoveryCodesVisible = ref(false);
10+
const recoveryCodeSectionRef = ref<HTMLDivElement | null>(null);
11+
12+
const fetchRecoveryCodes = async () => {
13+
try {
14+
const response = await fetch(route('two-factor.recovery-codes'), {
15+
headers: { Accept: 'application/json' },
16+
});
17+
recoveryCodesList.value = await response.json();
18+
} catch (error) {
19+
console.error('Failed to fetch recovery codes:', error);
20+
}
21+
};
22+
23+
const toggleRecoveryCodesVisibility = async () => {
24+
const isCurrentlyHidden = !isRecoveryCodesVisible.value;
25+
const hasNoCodes = !recoveryCodesList.value.length;
26+
27+
if (isCurrentlyHidden && hasNoCodes) {
28+
await fetchRecoveryCodes();
29+
}
30+
31+
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
32+
33+
if (isRecoveryCodesVisible.value) {
34+
await nextTick();
35+
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
36+
}
37+
};
38+
39+
onMounted(async () => {
40+
if (!recoveryCodesList.value.length) {
41+
await fetchRecoveryCodes();
42+
}
43+
});
44+
</script>
45+
46+
<template>
47+
<Card>
48+
<CardHeader>
49+
<CardTitle class="flex gap-3">
50+
<LockKeyhole class="size-4" />2FA Recovery Codes
51+
</CardTitle>
52+
<CardDescription>
53+
Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.
54+
</CardDescription>
55+
</CardHeader>
56+
<CardContent>
57+
<div class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between">
58+
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
59+
<component :is="isRecoveryCodesVisible ? EyeOff : Eye" class="size-4" />
60+
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} Recovery Codes
61+
</Button>
62+
63+
<Form
64+
v-if="isRecoveryCodesVisible"
65+
:action="route('two-factor.recovery-codes')"
66+
method="post"
67+
:options="{ preserveScroll: true }"
68+
@before="recoveryCodesList = []"
69+
@success="fetchRecoveryCodes"
70+
#default="{ processing }"
71+
>
72+
<Button variant="secondary" type="submit" :disabled="processing">
73+
<RefreshCw class="mr-2 size-4" :class="{ 'animate-spin': processing }" />
74+
{{ processing ? 'Regenerating...' : 'Regenerate Codes' }}
75+
</Button>
76+
</Form>
77+
</div>
78+
<div
79+
:class="[
80+
'relative overflow-hidden transition-all duration-300',
81+
isRecoveryCodesVisible ? 'h-auto opacity-100' : 'h-0 opacity-0',
82+
]"
83+
>
84+
<div class="mt-3 space-y-3">
85+
<div ref="recoveryCodeSectionRef" class="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm">
86+
<div v-if="!recoveryCodesList.length" class="space-y-2">
87+
<div v-for="n in 8" :key="n" class="h-4 bg-muted-foreground/20 rounded animate-pulse"></div>
88+
</div>
89+
<div v-else v-for="(code, index) in recoveryCodesList" :key="index">
90+
{{ code }}
91+
</div>
92+
</div>
93+
<p class="text-xs text-muted-foreground select-none">
94+
Each can be used once to access your account and
95+
will be removed after use. If you need more, click <span class="font-bold">Regenerate Codes</span> above.
96+
</p>
97+
</div>
98+
</div>
99+
</CardContent>
100+
</Card>
101+
</template>
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
<script setup lang="ts">
2+
import InputError from '@/components/InputError.vue';
3+
import { Button } from '@/components/ui/button';
4+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
5+
import { PinInput, PinInputGroup, PinInputSlot } from '@/components/ui/pin-input';
6+
import { useClipboard } from '@/composables/useClipboard';
7+
import { Form } from '@inertiajs/vue3';
8+
import { Check, Copy, Loader2, ScanLine } from 'lucide-vue-next';
9+
import { computed, nextTick, ref, watch } from 'vue';
10+
11+
interface Props {
12+
isOpen: boolean;
13+
requiresConfirmation: boolean;
14+
twoFactorEnabled: boolean;
15+
}
16+
17+
interface Emits {
18+
(e: 'update:isOpen', value: boolean): void;
19+
}
20+
21+
const props = defineProps<Props>();
22+
const emit = defineEmits<Emits>();
23+
24+
const { recentlyCopied, copyToClipboard } = useClipboard();
25+
26+
const qrCodeSvg = ref<string | null>(null);
27+
const manualSetupKey = ref<string | null>(null);
28+
const hasSetupData = computed(() => {
29+
return qrCodeSvg.value !== null && manualSetupKey.value !== null;
30+
});
31+
32+
const showVerificationStep = ref(false);
33+
const code = ref<number[]>([]);
34+
const codeValue = computed(() => code.value.join(''));
35+
36+
const pinInputContainerRef = ref<HTMLElement | null>(null);
37+
38+
const modalConfig = computed(() => {
39+
if (props.twoFactorEnabled) {
40+
return {
41+
title: 'You have enabled two factor authentication.',
42+
description: 'Two factor authentication is now enabled, scan the QR code or enter the setup key in authenticator app.',
43+
buttonText: 'Close',
44+
};
45+
}
46+
47+
if (showVerificationStep.value) {
48+
return {
49+
title: 'Verify Authentication Code',
50+
description: 'Enter the 6-digit code from your authenticator app',
51+
buttonText: 'Continue',
52+
};
53+
}
54+
55+
return {
56+
title: 'Turn on 2-step Verification',
57+
description: 'To finish enabling two factor authentication, scan the QR code or enter the setup key in authenticator app',
58+
buttonText: 'Continue',
59+
};
60+
});
61+
62+
const handleModalNextStep = () => {
63+
if (props.requiresConfirmation) {
64+
showVerificationStep.value = true;
65+
nextTick(() => {
66+
pinInputContainerRef.value?.querySelector('input')?.focus();
67+
});
68+
return;
69+
}
70+
71+
emit('update:isOpen', false);
72+
return;
73+
};
74+
75+
const fetchSetupData = async () => {
76+
try {
77+
const [qrResponse, keyResponse] = await Promise.all([
78+
fetch(route('two-factor.qr-code'), { headers: { Accept: 'application/json' } }),
79+
fetch(route('two-factor.secret-key'), { headers: { Accept: 'application/json' } }),
80+
]);
81+
82+
const { svg } = await qrResponse.json();
83+
const { secretKey } = await keyResponse.json();
84+
85+
qrCodeSvg.value = svg;
86+
manualSetupKey.value = secretKey;
87+
} catch (error) {
88+
console.error('Failed to fetch 2FA setup data:', error);
89+
}
90+
};
91+
92+
watch(
93+
() => props.isOpen,
94+
(isOpen) => {
95+
if (isOpen && !qrCodeSvg.value) {
96+
fetchSetupData();
97+
}
98+
},
99+
);
100+
101+
defineExpose({ hasSetupData });
102+
</script>
103+
104+
<template>
105+
<Dialog :open="isOpen" @update:open="emit('update:isOpen', $event)">
106+
<DialogContent class="sm:max-w-md">
107+
<DialogHeader class="flex items-center justify-center">
108+
<div class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm">
109+
<div class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5">
110+
<div class="absolute inset-0 grid grid-cols-5 opacity-50">
111+
<div v-for="i in 5" :key="`col-${i}`" class="border-r border-border last:border-r-0" />
112+
</div>
113+
<div class="absolute inset-0 grid grid-rows-5 opacity-50">
114+
<div v-for="i in 5" :key="`row-${i}`" class="border-b border-border last:border-b-0" />
115+
</div>
116+
<ScanLine class="relative z-20 size-6 text-foreground" />
117+
</div>
118+
</div>
119+
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
120+
<DialogDescription class="text-center">
121+
{{ modalConfig.description }}
122+
</DialogDescription>
123+
</DialogHeader>
124+
125+
<div class="relative flex w-auto flex-col items-center justify-center space-y-5">
126+
<template v-if="!showVerificationStep">
127+
<div class="relative mx-auto flex max-w-md items-center overflow-hidden">
128+
<div class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border">
129+
<div
130+
v-if="!qrCodeSvg"
131+
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
132+
>
133+
<Loader2 class="size-6 animate-spin" />
134+
</div>
135+
<div v-else class="relative z-10 overflow-hidden border p-5">
136+
<div v-html="qrCodeSvg" class="flex aspect-square size-full items-center justify-center" />
137+
</div>
138+
</div>
139+
</div>
140+
141+
<div class="flex w-full items-center space-x-5">
142+
<Button class="w-full" @click="handleModalNextStep">
143+
{{ modalConfig.buttonText }}
144+
</Button>
145+
</div>
146+
147+
<div class="relative flex w-full items-center justify-center">
148+
<div class="absolute inset-0 top-1/2 h-px w-full bg-border" />
149+
<span class="relative bg-card px-2 py-1">or, enter the code manually</span>
150+
</div>
151+
152+
<div class="flex w-full items-center justify-center space-x-2">
153+
<div class="flex w-full items-stretch overflow-hidden rounded-xl border border-border">
154+
<div v-if="!manualSetupKey" class="flex h-full w-full items-center justify-center bg-muted p-3">
155+
<Loader2 class="size-4 animate-spin" />
156+
</div>
157+
<template v-else>
158+
<input type="text" readonly :value="manualSetupKey" class="h-full w-full bg-background p-3 text-foreground" />
159+
<button
160+
@click="copyToClipboard(manualSetupKey || '')"
161+
class="relative block h-auto border-l border-border px-3 hover:bg-muted"
162+
>
163+
<Check v-if="recentlyCopied" class="w-4 text-green-500" />
164+
<Copy v-else class="w-4" />
165+
</button>
166+
</template>
167+
</div>
168+
</div>
169+
</template>
170+
171+
<template v-else>
172+
<Form
173+
:action="route('two-factor.confirm')"
174+
method="post"
175+
reset-on-error
176+
@error="code = []"
177+
@success="emit('update:isOpen', false)"
178+
v-slot="{ errors, processing }"
179+
>
180+
<input type="hidden" name="code" :value="codeValue" />
181+
<div ref="pinInputContainerRef" class="relative w-full space-y-3">
182+
<div class="flex w-full flex-col items-center justify-center space-y-3 py-2">
183+
<PinInput id="otp" placeholder="" v-model="code" type="number" otp>
184+
<PinInputGroup>
185+
<PinInputSlot autofocus v-for="(id, index) in 6" :key="id" :index="index" :disabled="processing" />
186+
</PinInputGroup>
187+
</PinInput>
188+
<InputError :message="errors?.confirmTwoFactorAuthentication?.code" />
189+
</div>
190+
191+
<div class="flex w-full items-center space-x-5">
192+
<Button
193+
type="button"
194+
variant="outline"
195+
class="w-auto flex-1"
196+
@click="showVerificationStep = false"
197+
:disabled="processing"
198+
>
199+
Back
200+
</Button>
201+
<Button type="submit" class="w-auto flex-1" :disabled="processing || codeValue.length < 6">
202+
{{ processing ? 'Confirming...' : 'Confirm' }}
203+
</Button>
204+
</div>
205+
</div>
206+
</Form>
207+
</template>
208+
</div>
209+
</DialogContent>
210+
</Dialog>
211+
</template>

resources/js/pages/auth/TwoFactorChallenge.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface AuthConfigContent {
1313
toggleText: string;
1414
}
1515
16-
const authConfigContent: ComputedRef<AuthConfigContent> = computed((): AuthConfigContent => {
16+
const authConfigContent = computed((): AuthConfigContent => {
1717
if (showRecoveryInput.value) {
1818
return {
1919
title: 'Recovery Code',
@@ -52,6 +52,7 @@ const codeValue: ComputedRef<string> = computed(() => code.value.join(''));
5252
method="post"
5353
class="space-y-4"
5454
reset-on-error
55+
@error="code=[]"
5556
#default="{ errors, processing, clearErrors }"
5657
>
5758
<input type="hidden" name="code" :value="codeValue" />
@@ -80,7 +81,7 @@ const codeValue: ComputedRef<string> = computed(() => code.value.join(''));
8081
</template>
8182

8283
<template v-else>
83-
<Form :action="route('two-factor.login')" method="post" class="space-y-4" #default="{ errors, processing, clearErrors }">
84+
<Form :action="route('two-factor.login')" method="post" class="space-y-4" reset-on-error #default="{ errors, processing, clearErrors }">
8485
<Input name="recovery_code" type="text" placeholder="Enter recovery code" :autofocus="showRecoveryInput" required />
8586
<InputError :message="errors.recovery_code" />
8687
<Button type="submit" class="w-full" :disabled="processing">Continue</Button>

0 commit comments

Comments
 (0)