Teren-app/resources/js/Pages/Profile/Partials/TwoFactorAuthenticationForm.vue
2026-01-19 19:24:41 +01:00

328 lines
9.9 KiB
Vue

<script setup>
import { ref, computed, watch } from "vue";
import { router, useForm, usePage } from "@inertiajs/vue3";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Badge } from "@/Components/ui/badge";
import ConfirmsPassword from "@/Components/ConfirmsPassword.vue";
import InputError from "@/Components/InputError.vue";
import { Shield, Key, Copy, RefreshCw, CheckCircle, AlertCircle } from "lucide-vue-next";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
const props = defineProps({
requiresConfirmation: Boolean,
});
const page = usePage();
const enabling = ref(false);
const confirming = ref(false);
const disabling = ref(false);
const qrCode = ref(null);
const setupKey = ref(null);
const recoveryCodes = ref([]);
const confirmationForm = useForm({
code: "",
});
const twoFactorEnabled = computed(
() => !enabling.value && page.props.auth.user?.two_factor_enabled
);
watch(twoFactorEnabled, () => {
if (!twoFactorEnabled.value) {
confirmationForm.reset();
confirmationForm.clearErrors();
}
});
const enableTwoFactorAuthentication = () => {
enabling.value = true;
router.post(
route("two-factor.enable"),
{},
{
preserveScroll: true,
onSuccess: () => Promise.all([showQrCode(), showSetupKey(), showRecoveryCodes()]),
onFinish: () => {
enabling.value = false;
confirming.value = props.requiresConfirmation;
},
}
);
};
const showQrCode = () => {
return axios.get(route("two-factor.qr-code")).then((response) => {
qrCode.value = response.data.svg;
});
};
const showSetupKey = () => {
return axios.get(route("two-factor.secret-key")).then((response) => {
setupKey.value = response.data.secretKey;
});
};
const showRecoveryCodes = () => {
return axios.get(route("two-factor.recovery-codes")).then((response) => {
recoveryCodes.value = response.data;
});
};
const confirmTwoFactorAuthentication = () => {
confirmationForm.post(route("two-factor.confirm"), {
errorBag: "confirmTwoFactorAuthentication",
preserveScroll: true,
preserveState: true,
onSuccess: () => {
confirming.value = false;
qrCode.value = null;
setupKey.value = null;
},
});
};
const regenerateRecoveryCodes = () => {
axios.post(route("two-factor.recovery-codes")).then(() => showRecoveryCodes());
};
const disableTwoFactorAuthentication = () => {
disabling.value = true;
router.delete(route("two-factor.disable"), {
preserveScroll: true,
onSuccess: () => {
disabling.value = false;
confirming.value = false;
},
});
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error("Failed to copy:", err);
}
};
</script>
<template>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class="p-4 border-t"
>
<template #header>
<div class="flex items-center gap-2">
<Shield size="18" />
<CardTitle>Dvonivojska overitev</CardTitle>
</div>
<CardDescription>
Dodatna varnost za vaš račun z dvonivojsko overitvijo.
</CardDescription>
</template>
<!-- Status Header -->
<div class="flex items-start gap-3">
<div class="flex-1">
<h3
v-if="twoFactorEnabled && !confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<CheckCircle class="h-5 w-5 text-green-600" />
Dvonivojska overitev omogočena
</h3>
<h3
v-else-if="twoFactorEnabled && confirming"
class="text-lg font-semibold flex items-center gap-2"
>
<AlertCircle class="h-5 w-5 text-amber-600" />
Dokončaj namestitev dvonivojske overitve
</h3>
<h3 v-else class="text-lg font-semibold flex items-center gap-2">
Dvonivojska overitev onemogočena
</h3>
</div>
</div>
<!-- QR Code & Setup -->
<div v-if="twoFactorEnabled" class="space-y-6">
<div v-if="qrCode" class="space-y-4">
<div class="rounded-lg border bg-muted/50 p-4">
<p v-if="confirming" class="text-sm font-medium mb-4">
Za dokončanje omogočanja dvostopenjske overitve skenirajte naslednjo QR-kodo z
aplikacijo za preverjanje pristnosti na vašem telefonu ali vnesite
namestitveno kodo in vpišite ustvarjeno OTP-kodo.
</p>
<p v-else class="text-sm text-muted-foreground mb-4">
Dvonivojska overitev je zdaj omogočena. Skenirajte QR kodo z aplikacijo za
preverjanje pristnosti na vašem telefonu ali vnesite namestitveni ključ.
</p>
<!-- QR Code -->
<div class="flex justify-center p-4 bg-white rounded-lg" v-html="qrCode" />
<!-- Setup Key -->
<div v-if="setupKey" class="mt-4 p-3 bg-background rounded-lg border">
<div class="flex items-center justify-between gap-2">
<div class="flex-1">
<Label class="text-xs text-muted-foreground">Namestitveni Ključ</Label>
<p class="font-mono text-sm font-semibold mt-1" v-html="setupKey"></p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
@click="copyToClipboard(setupKey)"
>
<Copy class="h-4 w-4" />
</Button>
</div>
</div>
</div>
<!-- Confirmation Code Input -->
<div v-if="confirming" class="space-y-2">
<Label for="code">Potrdite kodo</Label>
<Input
id="code"
v-model="confirmationForm.code"
type="text"
name="code"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
placeholder="Enter 6-digit code"
class="max-w-xs"
@keyup.enter="confirmTwoFactorAuthentication"
/>
<InputError :message="confirmationForm.errors.code" class="mt-2" />
</div>
</div>
<!-- Recovery Codes -->
<div v-if="recoveryCodes.length > 0 && !confirming" class="space-y-4">
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950"
>
<div class="flex items-start gap-2">
<Key
class="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
/>
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">
Shranite to obnovitveno kodo v upravitelja gesel. Lahko se uporabi za obnovo
vstopa v vaš račun, če se izgubi naprava z dvostopenjskim overjanjem.
</p>
</div>
</div>
<div class="rounded-lg border bg-muted p-4">
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
<div
v-for="code in recoveryCodes"
:key="code"
class="flex items-center justify-between p-2 bg-background rounded border"
>
<span>{{ code }}</span>
<Button
type="button"
variant="ghost"
size="sm"
class="h-6 w-6 p-0"
@click="copyToClipboard(code)"
>
<Copy class="h-3 w-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<!-- Action Buttons -->
<div class="flex flex-row gap-2 items-center justify-end w-full">
<!-- Enable -->
<div v-if="!twoFactorEnabled">
<ConfirmsPassword @confirmed="enableTwoFactorAuthentication">
<Button type="button" :disabled="enabling">
<Shield class="h-4 w-4 mr-2" />
Enable
</Button>
</ConfirmsPassword>
</div>
<!-- Confirm -->
<template v-else>
<ConfirmsPassword @confirmed="confirmTwoFactorAuthentication">
<Button v-if="confirming" type="button" :disabled="enabling">
<CheckCircle class="h-4 w-4 mr-2" />
Confirm
</Button>
</ConfirmsPassword>
<!-- Regenerate Recovery Codes -->
<ConfirmsPassword @confirmed="regenerateRecoveryCodes">
<Button
v-if="recoveryCodes.length > 0 && !confirming"
type="button"
variant="outline"
>
<RefreshCw class="h-4 w-4 mr-2" />
Regenerate Recovery Codes
</Button>
</ConfirmsPassword>
<!-- Show Recovery Codes -->
<ConfirmsPassword @confirmed="showRecoveryCodes">
<Button
v-if="recoveryCodes.length === 0 && !confirming"
type="button"
variant="outline"
>
<Key class="h-4 w-4 mr-2" />
Show Recovery Codes
</Button>
</ConfirmsPassword>
<!-- Cancel/Disable -->
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="confirming"
type="button"
variant="outline"
:disabled="disabling"
>
Cancel
</Button>
</ConfirmsPassword>
<ConfirmsPassword @confirmed="disableTwoFactorAuthentication">
<Button
v-if="!confirming"
type="button"
variant="destructive"
:disabled="disabling"
>
Disable
</Button>
</ConfirmsPassword>
</template>
</div>
</template>
</AppCard>
</template>