new funcionality

This commit is contained in:
2026-07-27 17:20:02 +02:00
parent c2b5291a74
commit c5ce8a7a36
101 changed files with 6889 additions and 176 deletions

View File

@@ -0,0 +1,259 @@
<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>