From f8eb9cde72ac759a637f488b273d2c818c50841f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 20 Feb 2026 11:40:46 +0100 Subject: [PATCH 1/5] feat(frontend): move 2FA management into dedicated tab with inline stepper Replace the confusing inline 2FA sections on the General account page with a dedicated Manage 2FA tab that guides users through a clear 5-step wizard (CAPTCHA, send OTP, verify OTP, scan QR, verify TOTP) and shows a clean status view when 2FA is already enabled. --- messages/en.json | 13 + src/constants/accountTabs.ts | 2 + .../settings/account/ManageTwoFactor.vue | 559 ++++++++++++++++++ src/pages/settings/account/index.vue | 389 +----------- src/route-map.d.ts | 13 + 5 files changed, 591 insertions(+), 385 deletions(-) create mode 100644 src/pages/settings/account/ManageTwoFactor.vue diff --git a/messages/en.json b/messages/en.json index 5518934a68..89c5d4e959 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6,6 +6,10 @@ "2fa-contact-members-before-enabling": "We recommend contacting these members and asking them to enable 2FA in their account settings before you proceed.", "2fa-disabled": "Disabled 2FA", "2fa-enabled": "2FA Enabled", + "2fa-is-enabled": "Two-factor authentication is enabled", + "2fa-is-enabled-description": "Your account is protected with two-factor authentication.", + "2fa-is-not-enabled": "Two-factor authentication is not enabled", + "2fa-is-not-enabled-description": "Protect your account by enabling two-factor authentication.", "2fa-enforcement-description": "When enabled, all organization members must have two-factor authentication enabled to access this organization. Members without 2FA will be locked out until they enable it.", "2fa-enforcement-disabled": "2FA enforcement has been disabled.", "2fa-enforcement-enable-anyway": "Enable Anyway", @@ -18,8 +22,15 @@ "2fa-members-status": "Members 2FA Status", "2fa-members-will-be-impacted": "{count} member(s) will be impacted", "2fa-not-enabled": "2FA Not Enabled", + "2fa-setup-date": "Set up on {date}", "2fa-setup-org-access": "Enable two-factor authentication to access this organization. Your organization requires 2FA for all members.", "2fa-setup-required": "Two-Factor Authentication Required", + "2fa-step-captcha": "Solve CAPTCHA", + "2fa-step-enter-code": "Enter verification code", + "2fa-step-scan-qr": "Scan QR code", + "2fa-step-send-code": "Send verification code", + "2fa-step-verify-totp": "Verify authenticator code", + "2fa-verify-and-enable": "Verify & Enable 2FA", "email-otp-2fa-title": "Email verification for 2FA", "email-otp-2fa-description": "Verify your email with a one-time code before enabling 2FA. Verification expires after 1 hour.", "email-otp-code-required": "Enter the verification code", @@ -891,6 +902,7 @@ "logs": "Logs", "low-adoption-warning": "Low adoption - only {percent}% on latest", "major": "Major", + "manage-2fa": "Manage 2FA", "manage-default-channel": "Manage in App settings", "manifest": "Manifest", "manifest-already-cached": "Already cached", @@ -1241,6 +1253,7 @@ "required-encryption-key-description": "Lock bundle uploads to a specific encryption key. Only bundles encrypted with this key will be accepted.", "required-encryption-key-help": "Enter the first 21 characters of your base64-encoded public key. Leave empty to accept any encrypted bundle.", "required-encryption-key-placeholder": "Enter 21-character key fingerprint", + "resend": "Resend", "resend-email": "Resend Confirmation Email", "reset-password": "Reset Password", "reset-spoofed-user": "Stop spoofing", diff --git a/src/constants/accountTabs.ts b/src/constants/accountTabs.ts index 1b69136f6c..1883b47686 100644 --- a/src/constants/accountTabs.ts +++ b/src/constants/accountTabs.ts @@ -2,9 +2,11 @@ import type { Tab } from '~/components/comp_def' import IconBell from '~icons/heroicons/bell' import IconInfo from '~icons/heroicons/information-circle' import IconLock from '~icons/heroicons/lock-closed' +import IconShieldCheck from '~icons/heroicons/shield-check' export const accountTabs: Tab[] = [ { label: 'general', key: '/settings/account', icon: IconInfo }, { label: 'notifications', key: '/settings/account/notifications', icon: IconBell }, { label: 'change-password', key: '/settings/account/change-password', icon: IconLock }, + { label: 'manage-2fa', key: '/settings/account/manage-2fa', icon: IconShieldCheck }, ] diff --git a/src/pages/settings/account/ManageTwoFactor.vue b/src/pages/settings/account/ManageTwoFactor.vue new file mode 100644 index 0000000000..c05eac00c7 --- /dev/null +++ b/src/pages/settings/account/ManageTwoFactor.vue @@ -0,0 +1,559 @@ + + + + + +path: /settings/account/manage-2fa +meta: + layout: settings + diff --git a/src/pages/settings/account/index.vue b/src/pages/settings/account/index.vue index 2d49f8df20..e997dfce4c 100644 --- a/src/pages/settings/account/index.vue +++ b/src/pages/settings/account/index.vue @@ -4,7 +4,7 @@ import { Capacitor } from '@capacitor/core' import { setErrors } from '@formkit/core' import { FormKit, FormKitMessages, reset } from '@formkit/vue' import dayjs from 'dayjs' -import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' @@ -36,41 +36,9 @@ const deleteAccountPassword = ref('') const deleteAccountCaptchaToken = ref('') const deleteAccountCaptchaRef = ref | null>(null) const captchaKey = ref(import.meta.env.VITE_CAPTCHA_KEY) -// mfa = 2fa -const mfaEnabled = ref(false) -const mfaFactorId = ref('') -const mfaVerificationCode = ref('') -const mfaQRCode = ref('') -const otpVerificationCode = ref('') -const otpVerifiedAt = ref(null) -const otpVerificationLoading = ref(false) -const otpSending = ref(false) -const otpNow = ref(dayjs()) -let otpNowTimer: ReturnType | null = null const organizationsToDelete = ref([]) const paidOrganizationsToDelete = ref>([]) displayStore.NavTitle = t('account') -const otpEmail = computed(() => main.auth?.email ?? main.user?.email ?? '') -const otpVerifiedUntil = computed(() => { - if (!otpVerifiedAt.value) - return null - return dayjs(otpVerifiedAt.value).add(1, 'hour') -}) -const otpVerificationValid = computed(() => { - if (!otpVerifiedUntil.value) - return false - return otpNow.value.isBefore(otpVerifiedUntil.value) -}) -const otpVerificationStatus = computed(() => { - if (!otpVerifiedAt.value) - return 'none' - return otpVerificationValid.value ? 'valid' : 'expired' -}) -const otpVerifiedUntilLabel = computed(() => { - if (!otpVerifiedUntil.value) - return '' - return otpVerifiedUntil.value.format('YYYY-MM-DD HH:mm') -}) async function checkOrganizationImpact() { // Wait for organizations and main store to load @@ -479,259 +447,12 @@ async function submit(form: { first_name: string, last_name: string, email: stri isLoading.value = false } -async function disableMfa() { - dialogStore.openDialog({ - title: t('alert-2fa-disable'), - description: `${t('alert-not-reverse-message')} ${t('alert-disable-2fa-message')}?`, - buttons: [ - { - text: t('button-cancel'), - role: 'cancel', - }, - { - text: t('disable'), - role: 'danger', - id: 'confirm-button', - }, - ], - }) - const canceled = await dialogStore.onDialogDismiss() - - // User has changed his mind - keepin 2fa - if (canceled) - return - - // Remove 2fa - const factorId = mfaFactorId.value - if (!factorId) { - toast.error(t('mfa-fail')) - console.error('Factor id = null') - return - } - - const { error: unregisterError } = await supabase.auth.mfa.unenroll({ factorId }) - if (unregisterError) { - toast.error(t('mfa-fail')) - console.error('Cannot unregister MFA', unregisterError) - return - } - - mfaFactorId.value = '' - mfaEnabled.value = false - toast.success(t('2fa-disabled')) -} - -async function handleMfa() { - if (mfaEnabled.value) { - await disableMfa() - return - } - await loadOtpVerification() - if (!otpVerificationValid.value) { - toast.error(t('email-otp-required')) - return - } - const { data, error } = await supabase.auth.mfa.enroll({ - factorType: 'totp', - }) - - if (error) { - toast.error(t('mfa-fail')) - console.error(error) - return - } - - // Store QR code for display - mfaQRCode.value = data.totp.qr_code - - // Step 1: Show QR code - dialogStore.openDialog({ - title: t('enable-2FA'), - description: `${t('mfa-enable-instruction')}`, - size: 'lg', - preventAccidentalClose: true, - buttons: [ - { - text: t('verify'), - id: 'verify', - }, - ], - }) - const didCancel = await dialogStore.onDialogDismiss() - - if (didCancel) { - // User closed the window, go ahead and unregister mfa - const { error: unregisterError } = await supabase.auth.mfa.unenroll({ factorId: data.id }) - if (error) - console.error('Cannot unregister MFA', unregisterError) - mfaQRCode.value = '' - return - } - // Step 2: User has scanned the code - verify his claim - mfaVerificationCode.value = '' - mfaQRCode.value = '' - - dialogStore.openDialog({ - title: t('verify-2FA'), - description: `${t('mfa-enable-instruction-2')}`, - size: 'lg', - preventAccidentalClose: true, - buttons: [ - { - text: t('verify'), - id: 'verify', - handler: async () => { - // User has clicked the "verify button - let's check" - const verifyCode = mfaVerificationCode.value.replaceAll(' ', '') - - const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({ factorId: data.id }) - - if (challengeError) { - toast.error(t('mfa-fail')) - console.error('Cannot create MFA challenge', challengeError) - return false - } - - const { data: _verify, error: verifyError } = await supabase.auth.mfa.verify({ factorId: data.id, challengeId: challenge.id, code: verifyCode.trim() }) - if (verifyError) { - toast.error(t('mfa-invalid-code')) - return false - } - - toast.success(t('mfa-enabled')) - mfaEnabled.value = true - mfaFactorId.value = data.id - }, - }, - ], - }) - - // Check the cancel again - const didCancel2 = await dialogStore.onDialogDismiss() - if (didCancel2) { - // User closed the window, go ahead and unregister mfa - const { error: unregisterError } = await supabase.auth.mfa.unenroll({ factorId: data.id }) - if (error) - console.error('Cannot unregister MFA', unregisterError) - } -} - -async function loadOtpVerification() { - if (!main.auth?.id) - return - const { data, error } = await supabase - .from('user_security') - .select('email_otp_verified_at') - .eq('user_id', main.auth.id) - .maybeSingle() - - if (error) { - console.error('Cannot load email OTP status', error) - return - } - - otpVerifiedAt.value = data?.email_otp_verified_at ?? null -} - -async function sendOtpVerification() { - if (!otpEmail.value) { - toast.error(t('account-error')) - return - } - if (otpSending.value) - return - - otpSending.value = true - const { error } = await supabase.auth.signInWithOtp({ - email: otpEmail.value, - options: { - shouldCreateUser: false, - }, - }) - otpSending.value = false - - if (error) { - toast.error(t('verification-failed')) - console.error('Cannot send email OTP', error) - return - } - - otpVerificationCode.value = '' - toast.success(t('email-otp-sent')) -} - -async function verifyOtpForMfa() { - if (!otpEmail.value) { - toast.error(t('account-error')) - return - } - if (!main.auth?.id) - return - - const token = otpVerificationCode.value.replaceAll(' ', '') - if (!token) { - toast.error(t('email-otp-code-required')) - return - } - if (otpVerificationLoading.value) - return - - otpVerificationLoading.value = true - const { data, error: verifyError } = await supabase.functions.invoke('private/verify_email_otp', { - body: { token }, - }) - - otpVerificationLoading.value = false - - if (verifyError || !data?.verified_at) { - toast.error(t('verification-failed')) - console.error('Cannot verify email OTP', verifyError) - return - } - - otpVerifiedAt.value = data.verified_at - toast.success(t('email-otp-verified')) -} - onMounted(async () => { - otpNowTimer = setInterval(() => { - otpNow.value = dayjs() - }, 60000) - await loadOtpVerification() - const { data: mfaFactors, error } = await supabase.auth.mfa.listFactors() - if (error) { - console.error('Cannot get MFA factors', error) - return - } - - const unverified = mfaFactors.all.filter(factor => factor.status === 'unverified') - if (unverified && unverified.length > 0) { - console.log(`Found ${unverified.length} unverified MFA factors, removing all`) - const responses = await Promise.all(unverified.map(factor => supabase.auth.mfa.unenroll({ factorId: factor.id }))) - - responses.filter(res => !!res.error).forEach(() => console.error('Failed to unregister', error)) - } - - const hasMfa = mfaFactors?.all.find(factor => factor.status === 'verified') - mfaEnabled.value = !!hasMfa - - if (hasMfa) - mfaFactorId.value = hasMfa.id - - // Auto-trigger 2FA setup if redirected from enforcement card - if (route.query.setup2fa === 'true' && !mfaEnabled.value) { - // Clear the query param first, wait for it to complete, then open dialog - // This prevents the DialogV2's route watcher from closing the dialog - await router.replace({ query: {} }) - await nextTick() - handleMfa() + // Auto-redirect to Manage 2FA page if setup2fa query param is present + if (route.query.setup2fa === 'true') { + router.replace('/settings/account/manage-2fa?setup2fa=true') } }) - -onBeforeUnmount(() => { - if (otpNowTimer) - clearInterval(otpNowTimer) -})