Add Two-Factor Authentication (2FA) to login and profile management

- Integrated 2FA check and handling in user and employee login workflows with `TwoFactorVerify` component.
- Added 2FA setup, enable, and disable functionalities in `UserProfile` and related management views.
- Enhanced internationalization files with 2FA-related translations for multiple locales.
- Created `TwoFactorAuthService.js` to handle API calls for 2FA setup and verification processes.
This commit is contained in:
Jeppe Bundgaard
2026-02-23 22:22:36 +01:00
parent be60ce8fdf
commit b2a6ede616
10 changed files with 786 additions and 16 deletions
@@ -7,12 +7,17 @@ import {API_URL} from "@/config.js";
import { parseError, errors, clearErrors, addError, getError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
const employees = ref([]);
const employee_key = ref('');
const user_id = ref('');
// 2FA state
const requires2FA = ref(false);
const twoFactorToken = ref('');
const error = ref('');
const store = useStore();
@@ -27,8 +32,18 @@ const login = async () => {
password: employee_key.value,
g_recaptcha_response: reCAPTCHA_data.value.enabled ? grecaptcha.getResponse() : null,
});
const data = response.data.data || response.data;
// Check if 2FA is required
if (data['2fa_required'] || data.two_fa_required) {
requires2FA.value = true;
twoFactorToken.value = data['2fa_token'] || data.two_fa_token;
return;
}
// Save the token in the local storage
localStorage.setItem('token', response.data.data.token);
localStorage.setItem('token', data.token);
// Redirect to the dashboard
window.location.href = '/admin';
} catch (e) {
@@ -40,6 +55,12 @@ const login = async () => {
}
};
const on2FACancel = () => {
requires2FA.value = false;
twoFactorToken.value = '';
employee_key.value = '';
};
const getEmployees = async () => {
try {
const response = await axios.get(API_URL + '/public/employees');
@@ -87,7 +108,17 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
</script>
<template>
<form @submit.prevent="doNothing" class="mx-auto">
<!-- 2FA Verification -->
<TwoFactorVerify
v-if="requires2FA"
:two-factor-token="twoFactorToken"
user-type="employee"
redirect-path="/admin"
@cancel="on2FACancel"
/>
<!-- Normal Login Form -->
<form v-else @submit.prevent="doNothing" class="mx-auto">
<div class="form-field">
<label class="label">User ID</label>
<input type="text" v-model="user_id" name="user_id">
@@ -110,6 +141,8 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
<LoadButtonWhileAwait :disabled="!user_id || !employee_key" :loadFunction="login" class="submit" id="operator_login_button">Login</LoadButtonWhileAwait>
</form>
<!-- Hidden alternative forms -->
<div v-if="false">
<div class="columns is-multiline is-centered">
<div class="column is-3" v-for="employee in employees" :key="employee.id">
+32 -2
View File
@@ -9,6 +9,7 @@ import { parseError, errors, clearErrors, addError, getError } from "@/component
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
const { t } = useI18n();
@@ -16,6 +17,10 @@ const { t } = useI18n();
const passkeySupported = ref(false);
const isPasskeyLoading = ref(false);
// 2FA state
const requires2FA = ref(false);
const twoFactorToken = ref('');
onMounted(() => {
passkeySupported.value = isPasskeySupported();
});
@@ -35,8 +40,18 @@ const login = async () => {
password: password.value,
g_recaptcha_response: reCAPTCHA_data.value.enabled ? grecaptcha.getResponse() : null,
});
const data = response.data.data || response.data;
// Check if 2FA is required
if (data['2fa_required'] || data.two_fa_required) {
requires2FA.value = true;
twoFactorToken.value = data['2fa_token'] || data.two_fa_token;
return;
}
// Save the token in the local storage
localStorage.setItem('token', response.data.data.token);
localStorage.setItem('token', data.token);
// Set the success message
successMessage.value = "Du er nu logget ind!"
// Wait 500ms before reloading the page to show the success message
@@ -50,6 +65,12 @@ const login = async () => {
}
};
const on2FACancel = () => {
requires2FA.value = false;
twoFactorToken.value = '';
password.value = '';
};
const doNothing = () => {
// Do nothing
};
@@ -103,7 +124,16 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
</script>
<template>
<form @submit.prevent="doNothing">
<!-- 2FA Verification -->
<TwoFactorVerify
v-if="requires2FA"
:two-factor-token="twoFactorToken"
user-type="user"
@cancel="on2FACancel"
/>
<!-- Normal Login Form -->
<form v-else @submit.prevent="doNothing">
<div class="form-field">
<label class="label">{{ $t('auth.customer_number') }}</label>
<input type="text" v-model="customer_number" name="customer_number" />
+47 -9
View File
@@ -1,10 +1,13 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseError, getError, addError } from "@/components/request/HandleGlobalError.vue";
import axios from 'axios'
import { API_URL } from "@/config.js";
import { parseError, getError, addError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
const { t } = useI18n();
@@ -30,25 +33,51 @@ const username = ref('');
// Password (common)
const password = ref('');
// 2FA state
const requires2FA = ref(false);
const twoFactorToken = ref('');
const login = async () => {
clearErrors();
try {
let credentials = { password: password.value };
let requestBody = { password: password.value };
if (loginMethod.value === 'phone') {
credentials.phone_country_code = parseInt(phone_country_code.value);
credentials.phone = parseInt(phone.value);
requestBody.phone_country_code = parseInt(phone_country_code.value);
requestBody.phone = parseInt(phone.value);
} else {
credentials.username = username.value;
requestBody.username = username.value;
}
await SessionUser.auth.authenticateSubuser(credentials);
// Redirect happens automatically in authenticateSubuser via window.location.reload()
// Make direct API call to check for 2FA response
const response = await axios.post(API_URL + '/subusers/auth/password', requestBody);
const data = response.data.data || response.data;
// Check if 2FA is required
if (data['2fa_required'] || data.two_fa_required) {
requires2FA.value = true;
twoFactorToken.value = data['2fa_token'] || data.two_fa_token;
return;
}
// Save the session token
if (data.session) {
localStorage.setItem('token', data.session);
localStorage.setItem('is_subuser', 'true');
window.location.reload();
}
} catch (e) {
// Error is already parsed in authenticateSubuser
parseError(e, 'auth');
console.error(e);
}
};
const on2FACancel = () => {
requires2FA.value = false;
twoFactorToken.value = '';
password.value = '';
};
const doNothing = () => {
// Prevent form submission
};
@@ -72,7 +101,16 @@ const loginWithPasskey = async () => {
</script>
<template>
<form @submit.prevent="doNothing">
<!-- 2FA Verification -->
<TwoFactorVerify
v-if="requires2FA"
:two-factor-token="twoFactorToken"
user-type="subuser"
@cancel="on2FACancel"
/>
<!-- Normal Login Form -->
<form v-else @submit.prevent="doNothing">
<!-- Login method selector -->
<div class="form-field">
<label class="label">{{ t('auth.login_with') }}</label>
@@ -0,0 +1,218 @@
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { verify2FA } from "@/services/TwoFactorAuthService.js";
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
const { t } = useI18n();
const router = useRouter();
const props = defineProps({
twoFactorToken: {
type: String,
required: true
},
userType: {
type: String,
default: 'user', // 'user', 'employee', or 'subuser'
validator: (value) => ['user', 'employee', 'subuser'].includes(value)
},
redirectPath: {
type: String,
default: null
}
});
const emit = defineEmits(['success', 'cancel']);
const code = ref('');
const successMessage = ref('');
const verify = async () => {
clearErrors();
if (!code.value || code.value.length !== 6) {
parseError({ response: { data: { message: t('2fa.invalid_code') } } }, 'auth');
return;
}
try {
const result = await verify2FA(props.twoFactorToken, code.value);
// Handle the response based on user type
if (props.userType === 'subuser' && result.session) {
localStorage.setItem('token', result.session);
localStorage.setItem('is_subuser', 'true');
} else if (result.token) {
localStorage.setItem('token', result.token);
localStorage.removeItem('is_subuser');
}
successMessage.value = t('2fa.verification_success');
emit('success', result);
// Redirect after short delay
setTimeout(() => {
if (props.redirectPath) {
window.location.href = props.redirectPath;
} else if (props.userType === 'employee') {
window.location.href = '/admin';
} else {
router.go(0);
}
}, 500);
} catch (e) {
parseError(e, 'auth');
}
};
const cancel = () => {
emit('cancel');
};
const doNothing = () => {
// Prevent form submission
};
</script>
<template>
<div class="two-factor-verify">
<form @submit.prevent="doNothing">
<div class="has-text-centered mb-5">
<span class="icon is-large has-text-primary">
<i class="fas fa-shield-alt fa-3x"></i>
</span>
<h2 class="title is-4 mt-4">{{ $t('2fa.verify_title') }}</h2>
<p class="subtitle is-6 has-text-grey">{{ $t('2fa.verify_description') }}</p>
</div>
<div class="form-field">
<label class="label">{{ $t('2fa.code_label') }}</label>
<input
type="text"
v-model="code"
:placeholder="$t('2fa.code_placeholder')"
maxlength="6"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="one-time-code"
class="input-2fa-code"
name="2fa_code"
autofocus
/>
</div>
<!-- Error display -->
<div class="field" v-if="getError('auth')">
<p class="help is-danger" id="2fa_auth_alert_error">
{{ getError('auth') }}
</p>
</div>
<!-- Success message -->
<div class="field" v-if="successMessage">
<p class="help is-success" id="2fa_auth_alert_success">
{{ successMessage }}
</p>
</div>
<LoadButtonWhileAwait
:loadFunction="verify"
class="submit"
id="2fa-verify-button"
:disabled="code.length !== 6"
>
{{ $t('2fa.verify_button') }}
</LoadButtonWhileAwait>
<button
type="button"
class="submit cancel-btn"
@click="cancel"
>
{{ $t('2fa.back_to_login') }}
</button>
</form>
</div>
</template>
<style scoped>
.two-factor-verify {
width: 100%;
}
form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
margin-bottom: 40px;
}
.form-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-field label {
color: #13324C;
font-size: 16px;
line-height: 1;
}
form input {
border: 1.5px solid #333;
border-radius: 8px;
margin-top: 4px;
font-size: 16px;
line-height: 1;
}
form input:not([type="checkbox"]) {
min-height: 40px;
}
.input-2fa-code {
text-align: center;
font-size: 24px !important;
letter-spacing: 8px;
font-weight: bold;
padding: 12px;
}
.submit {
width: 100%;
border-radius: 30px;
background-color: #13324C;
border: 0;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
min-height: 43px;
cursor: pointer;
}
.submit:hover {
background-color: #1a4263;
}
.submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.cancel-btn {
background-color: transparent;
color: #13324C;
border: 1.5px solid #13324C;
}
.cancel-btn:hover {
background-color: #f5f5f5;
}
</style>
+34
View File
@@ -3849,5 +3849,39 @@
"not_supported_browser": "Din browser understøtter ikke adgangsnøgler. Prøv venligst at bruge en moderne browser som Chrome, Safari, Firefox eller Edge.",
"login_with_passkey": "Log ind med adgangsnøgle",
"login_failed": "Kunne ikke logge ind med adgangsnøgle. Prøv venligst igen."
},
"2fa": {
"title": "To-faktor-godkendelse",
"description": "Tilføj et ekstra sikkerhedslag til din konto ved at kræve en bekræftelseskode ved login.",
"status_enabled": "To-faktor-godkendelse er aktiveret",
"status_disabled": "To-faktor-godkendelse er deaktiveret",
"status_enabled_desc": "Din konto er beskyttet med et ekstra bekræftelsestrin.",
"status_disabled_desc": "Aktiver to-faktor-godkendelse for forbedret sikkerhed.",
"enable_button": "Aktiver to-faktor-godkendelse",
"disable_button": "Deaktiver to-faktor-godkendelse",
"setup_title": "Opsæt to-faktor-godkendelse",
"setup_step1": "Scan denne QR-kode med din godkendelsesapp (Google Authenticator, Authy, osv.):",
"setup_step2": "Indtast den 6-cifrede kode fra din godkendelsesapp for at bekræfte:",
"setup_manual": "Kan du ikke scanne? Indtast denne kode manuelt:",
"setup_error_title": "Opsætning mislykkedes",
"code_placeholder": "123456",
"code_label": "Bekræftelseskode",
"verify_button": "Bekræft",
"verify_title": "To-faktor bekræftelse",
"verify_description": "Indtast den 6-cifrede kode fra din godkendelsesapp.",
"verification_success": "Bekræftelse gennemført!",
"invalid_code": "Indtast venligst en gyldig 6-cifret kode.",
"enabled_title": "To-faktor-godkendelse aktiveret",
"enabled_message": "Din konto er nu beskyttet med to-faktor-godkendelse.",
"disabled_title": "To-faktor-godkendelse deaktiveret",
"disabled_message": "To-faktor-godkendelse er blevet deaktiveret for din konto.",
"disable_title": "Deaktiver to-faktor-godkendelse",
"disable_confirm": "Er du sikker på, at du vil deaktivere to-faktor-godkendelse? Dette vil gøre din konto mindre sikker.",
"disable_error_title": "Kunne ikke deaktivere",
"enter_code": "Indtast din bekræftelseskode",
"back_to_login": "Tilbage til login"
},
"common": {
"cancel": "Annuller"
}
}
+34
View File
@@ -3844,5 +3844,39 @@
"not_supported_browser": "Your browser doesn't support passkeys. Please try using a modern browser like Chrome, Safari, Firefox, or Edge.",
"login_with_passkey": "Sign in with Passkey",
"login_failed": "Failed to sign in with passkey. Please try again."
},
"2fa": {
"title": "Two-Factor Authentication",
"description": "Add an extra layer of security to your account by requiring a verification code when signing in.",
"status_enabled": "Two-Factor Authentication is enabled",
"status_disabled": "Two-Factor Authentication is disabled",
"status_enabled_desc": "Your account is protected with an additional verification step.",
"status_disabled_desc": "Enable two-factor authentication for enhanced security.",
"enable_button": "Enable Two-Factor Authentication",
"disable_button": "Disable Two-Factor Authentication",
"setup_title": "Set Up Two-Factor Authentication",
"setup_step1": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):",
"setup_step2": "Enter the 6-digit code from your authenticator app to verify:",
"setup_manual": "Can't scan? Enter this code manually:",
"setup_error_title": "Setup Failed",
"code_placeholder": "123456",
"code_label": "Verification Code",
"verify_button": "Verify",
"verify_title": "Two-Factor Verification",
"verify_description": "Enter the 6-digit code from your authenticator app.",
"verification_success": "Verification successful!",
"invalid_code": "Please enter a valid 6-digit code.",
"enabled_title": "Two-Factor Authentication Enabled",
"enabled_message": "Your account is now protected with two-factor authentication.",
"disabled_title": "Two-Factor Authentication Disabled",
"disabled_message": "Two-factor authentication has been disabled for your account.",
"disable_title": "Disable Two-Factor Authentication",
"disable_confirm": "Are you sure you want to disable two-factor authentication? This will make your account less secure.",
"disable_error_title": "Failed to Disable",
"enter_code": "Enter your verification code",
"back_to_login": "Back to Login"
},
"common": {
"cancel": "Cancel"
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Two-Factor Authentication Service
* Handles all 2FA-related API calls
*/
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* Setup 2FA - Generate a new TOTP secret
* @returns {Promise<{secret: string, qr_code_url: string}>}
*/
export const setup2FA = async () => {
const response = await authenticatedRequest("/auth/2fa/setup", "POST");
return response.data.data || response.data;
};
/**
* Enable 2FA - Verify a code and enable 2FA for the authenticated user/subuser
* @param {string} code - The 6-digit TOTP code
* @returns {Promise<void>}
*/
export const enable2FA = async (code) => {
const response = await authenticatedRequest("/auth/2fa/enable", "POST", { code });
return response.data.data || response.data;
};
/**
* Disable 2FA - Verify a code and disable 2FA for the authenticated user/subuser
* @param {string} code - The 6-digit TOTP code
* @returns {Promise<void>}
*/
export const disable2FA = async (code) => {
const response = await authenticatedRequest("/auth/2fa/disable", "POST", { code });
return response.data.data || response.data;
};
/**
* Verify 2FA code during login
* @param {string} twoFactorToken - The temporary 2FA verification token from login response
* @param {string} code - The 6-digit TOTP code
* @returns {Promise<{token?: string, session?: string}>}
*/
export const verify2FA = async (twoFactorToken, code) => {
const response = await authenticatedRequest("/auth/2fa/verify", "POST", {
"2fa_token": twoFactorToken,
code
});
return response.data.data || response.data;
};
export default {
setup: setup2FA,
enable: enable2FA,
disable: disable2FA,
verify: verify2FA
};
@@ -12,6 +12,8 @@ import MicrosoftAuthenticationBox
from "@/views/dashboards/userDashboard/profile/displays/MicrosoftAuthentication/MicrosoftAuthenticationBox.vue";
import PasskeyManagement
from "@/views/dashboards/userDashboard/profile/displays/Passkeys/PasskeyManagement.vue";
import TwoFactorManagement
from "@/views/dashboards/userDashboard/profile/displays/TwoFactor/TwoFactorManagement.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import { parseError, removeError, addError } from "@/components/request/HandleGlobalError.vue";
@@ -639,6 +641,17 @@ const onClickSaveWashCertificateEmail = async () => {
</h4>
<p class="subtitle is-7 has-text-grey">{{ $t('passkeys.description') }}</p>
<PasskeyManagement />
<!-- Two-Factor Authentication -->
<hr class="my-4"/>
<h4 class="title is-6">
<span class="icon">
<i class="fas fa-shield-alt"></i>
</span>
<span>{{ $t('2fa.title') }}</span>
</h4>
<p class="subtitle is-7 has-text-grey">{{ $t('2fa.description') }}</p>
<TwoFactorManagement />
</ConfigurationCategory>
<!-- Subuser - Profile Information -->
<ConfigurationCategory
@@ -749,6 +762,17 @@ const onClickSaveWashCertificateEmail = async () => {
</h4>
<p class="subtitle is-7 has-text-grey">{{ $t('passkeys.description') }}</p>
<PasskeyManagement />
<!-- Two-Factor Authentication for subusers -->
<hr class="my-4"/>
<h4 class="title is-6">
<span class="icon">
<i class="fas fa-shield-alt"></i>
</span>
<span>{{ $t('2fa.title') }}</span>
</h4>
<p class="subtitle is-7 has-text-grey">{{ $t('2fa.description') }}</p>
<TwoFactorManagement />
</ConfigurationCategory>
<!-- Subuser - Grant selection -->
<ConfigurationCategory
@@ -383,14 +383,14 @@ const getTransportIcon = (transport) => {
<!-- Add passkey button -->
<button
class="button is-primary mt-3"
class="button is-link is-small mt-2"
@click="registerPasskey"
:disabled="isLoading"
>
<span class="icon">
<span>
<i class="fas fa-plus"></i>
<span class="ml-2">{{ $t('passkeys.add_passkey') }}</span>
</span>
<span>{{ $t('passkeys.add_passkey') }}</span>
</button>
</template>
</div>
@@ -0,0 +1,304 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { setup2FA, enable2FA, disable2FA } from "@/services/TwoFactorAuthService.js";
import Swal from "sweetalert2";
const { t } = useI18n();
// State
const isLoading = ref(false);
const is2FAEnabled = ref(false);
const setupData = ref(null); // { secret, qr_code_url }
const showSetup = ref(false);
const verificationCode = ref('');
const error = ref('');
// Check if 2FA is enabled for current session
onMounted(async () => {
await check2FAStatus();
});
const check2FAStatus = async () => {
// For now, we'll re-fetch session data to get the two_factor_enabled flag
// This assumes the session endpoint returns two_factor_enabled
try {
const response = await SessionUser.request('/auth/session', 'GET');
const data = response.data.data || response.data;
is2FAEnabled.value = data.two_factor_enabled || false;
} catch (e) {
console.error('Failed to check 2FA status:', e);
}
};
// Start the 2FA setup process
const startSetup = async () => {
isLoading.value = true;
error.value = '';
try {
setupData.value = await setup2FA();
showSetup.value = true;
} catch (e) {
error.value = SessionUser.functions.parseErrorMessage(e);
Swal.fire({
title: t('2fa.setup_error_title'),
text: error.value,
icon: 'error',
});
} finally {
isLoading.value = false;
}
};
// Enable 2FA with verification code
const confirmEnable2FA = async () => {
if (!verificationCode.value || verificationCode.value.length !== 6) {
error.value = t('2fa.invalid_code');
return;
}
isLoading.value = true;
error.value = '';
try {
await enable2FA(verificationCode.value);
is2FAEnabled.value = true;
showSetup.value = false;
setupData.value = null;
verificationCode.value = '';
Swal.fire({
title: t('2fa.enabled_title'),
text: t('2fa.enabled_message'),
icon: 'success',
});
} catch (e) {
error.value = SessionUser.functions.parseErrorMessage(e);
} finally {
isLoading.value = false;
}
};
// Cancel setup
const cancelSetup = () => {
showSetup.value = false;
setupData.value = null;
verificationCode.value = '';
error.value = '';
};
// Disable 2FA
const onClickDisable2FA = async () => {
const result = await Swal.fire({
title: t('2fa.disable_title'),
text: t('2fa.disable_confirm'),
icon: 'warning',
input: 'text',
inputLabel: t('2fa.enter_code'),
inputPlaceholder: '123456',
inputAttributes: {
maxlength: 6,
autocapitalize: 'off',
autocorrect: 'off',
inputmode: 'numeric',
pattern: '[0-9]*',
},
showCancelButton: true,
confirmButtonColor: '#d33',
confirmButtonText: t('2fa.disable_button'),
cancelButtonText: t('common.cancel'),
preConfirm: (code) => {
if (!code || code.length !== 6) {
Swal.showValidationMessage(t('2fa.invalid_code'));
return false;
}
return code;
},
});
if (result.isConfirmed && result.value) {
isLoading.value = true;
try {
await disable2FA(result.value);
is2FAEnabled.value = false;
Swal.fire({
title: t('2fa.disabled_title'),
text: t('2fa.disabled_message'),
icon: 'success',
});
} catch (e) {
Swal.fire({
title: t('2fa.disable_error_title'),
text: SessionUser.functions.parseErrorMessage(e),
icon: 'error',
});
} finally {
isLoading.value = false;
}
}
};
</script>
<template>
<div class="two-factor-management">
<!-- 2FA Status Display -->
<div class="status-display mb-4">
<div class="is-flex is-align-items-center">
<span class="icon is-medium" :class="is2FAEnabled ? 'has-text-success' : 'has-text-grey'">
<i class="fas fa-shield-alt fa-lg"></i>
</span>
<div class="ml-3">
<p class="has-text-weight-semibold">
{{ is2FAEnabled ? $t('2fa.status_enabled') : $t('2fa.status_disabled') }}
</p>
<p class="is-size-7 has-text-grey">
{{ is2FAEnabled ? $t('2fa.status_enabled_desc') : $t('2fa.status_disabled_desc') }}
</p>
</div>
</div>
</div>
<!-- Setup Flow -->
<div v-if="showSetup && setupData" class="setup-flow box">
<h4 class="title is-5 mb-4">{{ $t('2fa.setup_title') }}</h4>
<!-- Step 1: QR Code -->
<div class="setup-step mb-4">
<p class="mb-3">{{ $t('2fa.setup_step1') }}</p>
<div class="qr-code-container has-text-centered mb-3">
<img
v-if="setupData.qr_code_url"
:src="setupData.qr_code_url.startsWith('otpauth://') ? `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.qr_code_url)}` : setupData.qr_code_url"
alt="2FA QR Code"
class="qr-code"
/>
</div>
</div>
<!-- Step 2: Manual Entry -->
<div class="setup-step mb-4">
<p class="mb-2">{{ $t('2fa.setup_manual') }}</p>
<div class="secret-key-box">
<code class="secret-key">{{ setupData.secret }}</code>
</div>
</div>
<!-- Step 3: Verify -->
<div class="setup-step">
<p class="mb-2">{{ $t('2fa.setup_step2') }}</p>
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
type="text"
v-model="verificationCode"
:placeholder="$t('2fa.code_placeholder')"
maxlength="6"
inputmode="numeric"
pattern="[0-9]*"
@keyup.enter="confirmEnable2FA"
/>
</div>
<div class="control">
<button
class="button is-success"
@click="confirmEnable2FA"
:disabled="isLoading || verificationCode.length !== 6"
:class="{ 'is-loading': isLoading }"
>
{{ $t('2fa.verify_button') }}
</button>
</div>
</div>
<p v-if="error" class="help is-danger mt-2">{{ error }}</p>
</div>
<!-- Cancel button -->
<button
class="button is-light mt-4"
@click="cancelSetup"
:disabled="isLoading"
>
{{ $t('common.cancel') }}
</button>
</div>
<!-- Action Buttons (when not in setup) -->
<div v-else class="buttons">
<button
v-if="!is2FAEnabled"
class="button is-link"
@click="startSetup"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon">
<i class="fas fa-shield-alt"></i>
</span>
<span>{{ $t('2fa.enable_button') }}</span>
</button>
<button
v-if="is2FAEnabled"
class="button is-danger is-outlined"
@click="onClickDisable2FA"
:disabled="isLoading"
:class="{ 'is-loading': isLoading }"
>
<span class="icon">
<i class="fas fa-shield-alt"></i>
</span>
<span>{{ $t('2fa.disable_button') }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.two-factor-management {
padding: 1rem 0;
}
.status-display {
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
.setup-flow {
background: #fafafa;
border: 1px solid #e0e0e0;
}
.qr-code-container {
padding: 1rem;
background: white;
border-radius: 8px;
display: inline-block;
}
.qr-code {
max-width: 200px;
height: auto;
}
.secret-key-box {
background: #f0f0f0;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
}
.secret-key {
font-size: 0.9rem;
letter-spacing: 2px;
word-break: break-all;
}
.buttons {
margin-top: 1rem;
}
</style>