260 lines
8.5 KiB
Vue
260 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref } from 'vue';
|
|
import axios from 'axios';
|
|
import QRCode from 'qrcode';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useAuthStore } from '../stores/auth';
|
|
|
|
const auth = useAuthStore();
|
|
const { t } = useI18n();
|
|
|
|
const loading = ref(false);
|
|
const errors = ref<Record<string, string[]>>({});
|
|
|
|
const settingUp = ref(false);
|
|
const otpauthUrl = ref('');
|
|
const secret = ref('');
|
|
const qrDataUrl = ref('');
|
|
const confirmCode = ref('');
|
|
|
|
const recoveryCodes = ref<string[] | null>(null);
|
|
|
|
const showDisableForm = ref(false);
|
|
const showRegenerateForm = ref(false);
|
|
const password = ref('');
|
|
|
|
async function startSetup(): Promise<void> {
|
|
loading.value = true;
|
|
errors.value = {};
|
|
|
|
try {
|
|
const { data } = await axios.post<{ secret: string; otpauth_url: string }>('/web-api/2fa/setup');
|
|
secret.value = data.secret;
|
|
otpauthUrl.value = data.otpauth_url;
|
|
qrDataUrl.value = await QRCode.toDataURL(data.otpauth_url);
|
|
settingUp.value = true;
|
|
} catch (e: any) {
|
|
alert(e.response?.data?.message ?? t('security.setupFailed'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function confirmSetup(): Promise<void> {
|
|
loading.value = true;
|
|
errors.value = {};
|
|
|
|
try {
|
|
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/confirm', { code: confirmCode.value });
|
|
recoveryCodes.value = data.recovery_codes;
|
|
cancelSetup();
|
|
await auth.fetchUser();
|
|
} catch (e: any) {
|
|
errors.value = e.response?.data?.errors ?? { code: [t('security.invalidCode')] };
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function cancelSetup(): void {
|
|
settingUp.value = false;
|
|
secret.value = '';
|
|
otpauthUrl.value = '';
|
|
qrDataUrl.value = '';
|
|
confirmCode.value = '';
|
|
errors.value = {};
|
|
}
|
|
|
|
async function disable(): Promise<void> {
|
|
loading.value = true;
|
|
errors.value = {};
|
|
|
|
try {
|
|
await axios.post('/web-api/2fa/disable', { password: password.value });
|
|
password.value = '';
|
|
showDisableForm.value = false;
|
|
recoveryCodes.value = null;
|
|
await auth.fetchUser();
|
|
} catch (e: any) {
|
|
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function regenerateRecoveryCodes(): Promise<void> {
|
|
loading.value = true;
|
|
errors.value = {};
|
|
|
|
try {
|
|
const { data } = await axios.post<{ recovery_codes: string[] }>('/web-api/2fa/recovery-codes', { password: password.value });
|
|
recoveryCodes.value = data.recovery_codes;
|
|
password.value = '';
|
|
showRegenerateForm.value = false;
|
|
} catch (e: any) {
|
|
errors.value = e.response?.data?.errors ?? { password: [t('security.invalidPassword')] };
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="security">
|
|
<div class="page-header">
|
|
<div>
|
|
<h1 class="page-title">{{ t('security.title') }}</h1>
|
|
<p class="page-subtitle">{{ t('security.subtitle') }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="recoveryCodes" class="banner banner--warning">
|
|
<p class="banner__title">{{ t('security.recoveryCodesTitle') }}</p>
|
|
<p class="banner__line">{{ t('security.recoveryCodesWarning') }}</p>
|
|
<ul class="security__codes">
|
|
<li v-for="rc in recoveryCodes" :key="rc"><code class="banner__code">{{ rc }}</code></li>
|
|
</ul>
|
|
<button class="banner__close" @click="recoveryCodes = null">{{ t('common.close') }}</button>
|
|
</div>
|
|
|
|
<div v-if="auth.user?.two_factor_enabled && !settingUp" class="card security__card">
|
|
<p class="security__status security__status--on">{{ t('security.enabled') }}</p>
|
|
<p class="security__desc">{{ t('security.enabledDesc') }}</p>
|
|
|
|
<div class="security__actions">
|
|
<button
|
|
@click="showRegenerateForm = !showRegenerateForm; showDisableForm = false; errors = {}"
|
|
type="button"
|
|
class="btn btn--secondary"
|
|
>
|
|
{{ t('security.regenerateCodes') }}
|
|
</button>
|
|
<button
|
|
@click="showDisableForm = !showDisableForm; showRegenerateForm = false; errors = {}"
|
|
type="button"
|
|
class="btn btn--secondary"
|
|
>
|
|
{{ t('security.disable') }}
|
|
</button>
|
|
</div>
|
|
|
|
<form v-if="showRegenerateForm" class="security__password-form" @submit.prevent="regenerateRecoveryCodes">
|
|
<div class="field">
|
|
<label class="field__label">{{ t('security.confirmPassword') }}</label>
|
|
<input v-model="password" type="password" required class="field__input" />
|
|
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
|
</div>
|
|
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
|
|
</form>
|
|
|
|
<form v-if="showDisableForm" class="security__password-form" @submit.prevent="disable">
|
|
<div class="field">
|
|
<label class="field__label">{{ t('security.confirmPassword') }}</label>
|
|
<input v-model="password" type="password" required class="field__input" />
|
|
<p v-if="errors.password" class="field__error">{{ errors.password[0] }}</p>
|
|
</div>
|
|
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.disableConfirm') }}</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div v-else-if="!settingUp" class="card security__card">
|
|
<p class="security__status security__status--off">{{ t('security.disabled') }}</p>
|
|
<p class="security__desc">{{ t('security.disabledDesc') }}</p>
|
|
<button @click="startSetup" :disabled="loading" type="button" class="btn btn--primary">{{ t('security.enable') }}</button>
|
|
</div>
|
|
|
|
<div v-else class="card security__card">
|
|
<h3 class="card__title">{{ t('security.setupTitle') }}</h3>
|
|
<p class="security__desc">{{ t('security.setupDesc') }}</p>
|
|
|
|
<img :src="qrDataUrl" :alt="t('security.qrAlt')" class="security__qr" />
|
|
|
|
<p class="security__manual">
|
|
{{ t('security.manualEntry') }} <code class="banner__code">{{ secret }}</code>
|
|
</p>
|
|
|
|
<form class="security__confirm-form" @submit.prevent="confirmSetup">
|
|
<div class="field">
|
|
<label class="field__label">{{ t('security.enterCode') }}</label>
|
|
<input v-model="confirmCode" required autofocus autocomplete="one-time-code" class="field__input" />
|
|
<p v-if="errors.code" class="field__error">{{ errors.code[0] }}</p>
|
|
</div>
|
|
<div class="security__actions">
|
|
<button type="submit" :disabled="loading" class="btn btn--primary">{{ t('security.confirm') }}</button>
|
|
<button type="button" @click="cancelSetup" class="btn btn--secondary">{{ t('common.cancel') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.security__card {
|
|
margin-top: 1.5rem;
|
|
max-width: 32rem;
|
|
}
|
|
|
|
.security__status {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.security__status--on {
|
|
color: var(--color-accent-strong);
|
|
}
|
|
|
|
.security__status--off {
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.security__desc {
|
|
margin-top: 0.25rem;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.security__actions {
|
|
margin-top: 1rem;
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.security__password-form {
|
|
margin-top: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.security__qr {
|
|
margin-top: 1rem;
|
|
width: 200px;
|
|
height: 200px;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
.security__manual {
|
|
margin-top: 0.75rem;
|
|
font-size: 0.8125rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.security__confirm-form {
|
|
margin-top: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.security__codes {
|
|
margin-top: 0.5rem;
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 0.375rem;
|
|
list-style: none;
|
|
padding: 0;
|
|
}
|
|
</style>
|