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:
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+304
@@ -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>
|
||||
Reference in New Issue
Block a user