170 lines
5.6 KiB
Vue
170 lines
5.6 KiB
Vue
<script setup lang="ts">
|
|
import { useForm } from '@inertiajs/vue3';
|
|
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
|
|
import { ref } from 'vue';
|
|
import AlertError from '@/components/AlertError.vue';
|
|
import AuthFormFeedback from '@/components/AuthFormFeedback.vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { useFormProtection } from '@/composables/useFormProtection';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
|
|
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
|
|
|
const { t } = useLocalization();
|
|
const {
|
|
recoveryCodesList,
|
|
fetchRecoveryCodes,
|
|
clearRecoveryCodes,
|
|
errors,
|
|
loading,
|
|
} = useTwoFactorAuth();
|
|
const visible = ref(false);
|
|
const showRegenerate = ref(false);
|
|
const form = useForm({});
|
|
const protection = useFormProtection(form);
|
|
|
|
function hide(): void {
|
|
visible.value = false;
|
|
clearRecoveryCodes();
|
|
}
|
|
function toggle(): void {
|
|
if (visible.value) {
|
|
hide();
|
|
} else {
|
|
visible.value = true;
|
|
void fetchRecoveryCodes();
|
|
}
|
|
}
|
|
function closeRegenerate(open: boolean): void {
|
|
if (!form.processing) {
|
|
showRegenerate.value = open;
|
|
protection.clearFeedback();
|
|
}
|
|
}
|
|
function regenerate(): void {
|
|
protection.submit(regenerateRecoveryCodes(), {
|
|
preserveScroll: true,
|
|
onBefore: hide,
|
|
onSuccess: () => {
|
|
showRegenerate.value = false;
|
|
visible.value = true;
|
|
return fetchRecoveryCodes();
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section
|
|
class="gap-column grid min-w-0 rounded-lg border p-4"
|
|
aria-labelledby="recovery-title"
|
|
>
|
|
<div class="gap-stack grid">
|
|
<h3
|
|
id="recovery-title"
|
|
class="flex items-center gap-2 font-semibold"
|
|
>
|
|
<LockKeyhole class="text-primary size-4" />{{
|
|
t('security.recoveryTitle')
|
|
}}
|
|
</h3>
|
|
<p class="text-body text-muted-foreground">
|
|
{{ t('security.recoveryDescription') }}
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-3">
|
|
<Button
|
|
variant="outline"
|
|
:aria-expanded="visible"
|
|
aria-controls="recovery-codes"
|
|
:disabled="form.processing"
|
|
@click="toggle"
|
|
><component :is="visible ? EyeOff : Eye" />{{
|
|
t(
|
|
visible
|
|
? 'security.hideRecovery'
|
|
: 'security.viewRecovery',
|
|
)
|
|
}}</Button
|
|
>
|
|
<Button
|
|
v-if="visible && recoveryCodesList.length"
|
|
variant="secondary"
|
|
@click="showRegenerate = true"
|
|
><RefreshCw />{{ t('security.regenerate') }}</Button
|
|
>
|
|
</div>
|
|
<div v-if="visible" id="recovery-codes" class="gap-stack grid">
|
|
<p
|
|
v-if="loading"
|
|
role="status"
|
|
class="bg-muted animate-pulse rounded-lg p-4"
|
|
>
|
|
{{ t('security.loadingRecovery') }}
|
|
</p>
|
|
<template v-else-if="errors.length">
|
|
<AlertError :errors="errors" />
|
|
<Button
|
|
class="justify-self-start"
|
|
variant="outline"
|
|
@click="fetchRecoveryCodes"
|
|
>{{ t('common.retry') }}</Button
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
<ul
|
|
class="bg-muted grid gap-2 rounded-lg p-4 font-mono text-sm sm:grid-cols-2"
|
|
:aria-label="t('security.recoveryTitle')"
|
|
>
|
|
<li v-for="code in recoveryCodesList" :key="code">
|
|
{{ code }}
|
|
</li>
|
|
</ul>
|
|
<p class="text-caption text-muted-foreground">
|
|
{{ t('security.recoveryOnce') }}
|
|
</p>
|
|
</template>
|
|
</div>
|
|
<Dialog :open="showRegenerate" @update:open="closeRegenerate">
|
|
<DialogContent
|
|
:show-close-button="!form.processing"
|
|
@escape-key-down="form.processing && $event.preventDefault()"
|
|
@interact-outside="form.processing && $event.preventDefault()"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{{
|
|
t('security.regenerateTitle')
|
|
}}</DialogTitle>
|
|
<DialogDescription>{{
|
|
t('security.regenerateDescription')
|
|
}}</DialogDescription>
|
|
</DialogHeader>
|
|
<AuthFormFeedback
|
|
:state="protection.state.value"
|
|
:response-status="protection.responseStatus.value"
|
|
@retry="regenerate"
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
:disabled="form.processing"
|
|
@click="closeRegenerate(false)"
|
|
>{{ t('common.cancel') }}</Button
|
|
>
|
|
<Button :loading="form.processing" @click="regenerate">{{
|
|
t('security.regenerate')
|
|
}}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
</template>
|