feat(auth): 实现 TOTP 双因素认证功能

新增功能:
- 支持 Google Authenticator 等应用进行 TOTP 二次验证
- 用户可在个人设置中启用/禁用 2FA
- 登录时支持 TOTP 验证流程
- 管理后台可全局开关 TOTP 功能

安全增强:
- TOTP 密钥使用 AES-256-GCM 加密存储
- 添加 TOTP_ENCRYPTION_KEY 配置项,必须手动配置才能启用功能
- 防止服务重启导致加密密钥变更使用户无法登录
- 验证失败次数限制,防止暴力破解

配置说明:
- Docker 部署:在 .env 中设置 TOTP_ENCRYPTION_KEY
- 非 Docker 部署:在 config.yaml 中设置 totp.encryption_key
- 生成密钥命令:openssl rand -hex 32
This commit is contained in:
shaw
2026-01-26 08:45:43 +08:00
parent 74e05b83ea
commit 1245f07a2d
60 changed files with 4140 additions and 350 deletions

View File

@@ -14,6 +14,8 @@ export interface SystemSettings {
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
totp_enabled: boolean // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置
// Default settings
default_balance: number
default_concurrency: number
@@ -68,6 +70,7 @@ export interface UpdateSettingsRequest {
email_verify_enabled?: boolean
promo_code_enabled?: boolean
password_reset_enabled?: boolean
totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number
default_concurrency?: number
site_name?: string

View File

@@ -11,9 +11,23 @@ import type {
CurrentUserResponse,
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings
PublicSettings,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
/**
* Login response type - can be either full auth or 2FA required
*/
export type LoginResponse = AuthResponse | TotpLoginResponse
/**
* Type guard to check if login response requires 2FA
*/
export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse {
return 'requires_2fa' in response && response.requires_2fa === true
}
/**
* Store authentication token in localStorage
*/
@@ -38,11 +52,28 @@ export function clearAuthToken(): void {
/**
* User login
* @param credentials - Username and password
* @param credentials - Email and password
* @returns Authentication response with token and user data, or 2FA required response
*/
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
// Only store token if 2FA is not required
if (!isTotp2FARequired(data)) {
setAuthToken(data.access_token)
localStorage.setItem('auth_user', JSON.stringify(data.user))
}
return data
}
/**
* Complete login with 2FA code
* @param request - Temp token and TOTP code
* @returns Authentication response with token and user data
*/
export async function login(credentials: LoginRequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/login', credentials)
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
// Store token and user data
setAuthToken(data.access_token)
@@ -186,6 +217,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
export const authAPI = {
login,
login2FA,
isTotp2FARequired,
register,
getCurrentUser,
logout,

View File

@@ -7,7 +7,7 @@
export { apiClient } from './client'
// Auth API
export { authAPI } from './auth'
export { authAPI, isTotp2FARequired, type LoginResponse } from './auth'
// User APIs
export { keysAPI } from './keys'
@@ -15,6 +15,7 @@ export { usageAPI } from './usage'
export { userAPI } from './user'
export { redeemAPI, type RedeemHistoryItem } from './redeem'
export { userGroupsAPI } from './groups'
export { totpAPI } from './totp'
// Admin APIs
export { adminAPI } from './admin'

83
frontend/src/api/totp.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* TOTP (2FA) API endpoints
* Handles Two-Factor Authentication with Google Authenticator
*/
import { apiClient } from './client'
import type {
TotpStatus,
TotpSetupRequest,
TotpSetupResponse,
TotpEnableRequest,
TotpEnableResponse,
TotpDisableRequest,
TotpVerificationMethod
} from '@/types'
/**
* Get TOTP status for current user
* @returns TOTP status including enabled state and feature availability
*/
export async function getStatus(): Promise<TotpStatus> {
const { data } = await apiClient.get<TotpStatus>('/user/totp/status')
return data
}
/**
* Get verification method for TOTP operations
* @returns Method ('email' or 'password') required for setup/disable
*/
export async function getVerificationMethod(): Promise<TotpVerificationMethod> {
const { data } = await apiClient.get<TotpVerificationMethod>('/user/totp/verification-method')
return data
}
/**
* Send email verification code for TOTP operations
* @returns Success response
*/
export async function sendVerifyCode(): Promise<{ success: boolean }> {
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/send-code')
return data
}
/**
* Initiate TOTP setup - generates secret and QR code
* @param request - Email code or password depending on verification method
* @returns Setup response with secret, QR code URL, and setup token
*/
export async function initiateSetup(request?: TotpSetupRequest): Promise<TotpSetupResponse> {
const { data } = await apiClient.post<TotpSetupResponse>('/user/totp/setup', request || {})
return data
}
/**
* Complete TOTP setup by verifying the code
* @param request - TOTP code and setup token
* @returns Enable response with success status and enabled timestamp
*/
export async function enable(request: TotpEnableRequest): Promise<TotpEnableResponse> {
const { data } = await apiClient.post<TotpEnableResponse>('/user/totp/enable', request)
return data
}
/**
* Disable TOTP for current user
* @param request - Email code or password depending on verification method
* @returns Success response
*/
export async function disable(request: TotpDisableRequest): Promise<{ success: boolean }> {
const { data } = await apiClient.post<{ success: boolean }>('/user/totp/disable', request)
return data
}
export const totpAPI = {
getStatus,
getVerificationMethod,
sendVerifyCode,
initiateSetup,
enable,
disable
}
export default totpAPI

View File

@@ -0,0 +1,176 @@
<template>
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 transition-opacity"></div>
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
<!-- Header -->
<div class="mb-6 text-center">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<h3 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
{{ t('profile.totp.loginTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.loginHint') }}
</p>
<p v-if="userEmailMasked" class="mt-1 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ userEmailMasked }}
</p>
</div>
<!-- Code Input -->
<div class="mb-6">
<div class="flex justify-center gap-2">
<input
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setInputRef(el, index)"
type="text"
maxlength="1"
inputmode="numeric"
pattern="[0-9]"
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
:disabled="verifying"
@input="handleCodeInput($event, index)"
@keydown="handleKeydown($event, index)"
@paste="handlePaste"
/>
</div>
<!-- Loading indicator -->
<div v-if="verifying" class="mt-3 flex items-center justify-center gap-2 text-sm text-gray-500">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
{{ t('common.verifying') }}
</div>
</div>
<!-- Error -->
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ error }}
</div>
<!-- Cancel button only -->
<button
type="button"
class="btn btn-secondary w-full"
:disabled="verifying"
@click="$emit('cancel')"
>
{{ t('common.cancel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
tempToken: string
userEmailMasked?: string
}>()
const emit = defineEmits<{
verify: [code: string]
cancel: []
}>()
const { t } = useI18n()
const verifying = ref(false)
const error = ref('')
const code = ref<string[]>(['', '', '', '', '', ''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
// Watch for code changes and auto-submit when 6 digits are entered
watch(
() => code.value.join(''),
(newCode) => {
if (newCode.length === 6 && !verifying.value) {
emit('verify', newCode)
}
}
)
defineExpose({
setVerifying: (value: boolean) => { verifying.value = value },
setError: (message: string) => {
error.value = message
code.value = ['', '', '', '', '', '']
// Clear input DOM values
inputRefs.value.forEach(input => {
if (input) input.value = ''
})
nextTick(() => {
inputRefs.value[0]?.focus()
})
}
})
const setInputRef = (el: any, index: number) => {
inputRefs.value[index] = el as HTMLInputElement | null
}
const handleCodeInput = (event: Event, index: number) => {
const input = event.target as HTMLInputElement
const value = input.value.replace(/[^0-9]/g, '')
code.value[index] = value
if (value && index < 5) {
nextTick(() => {
inputRefs.value[index + 1]?.focus()
})
}
}
const handleKeydown = (event: KeyboardEvent, index: number) => {
if (event.key === 'Backspace') {
const input = event.target as HTMLInputElement
// If current cell is empty and not the first, move to previous cell
if (!input.value && index > 0) {
event.preventDefault()
inputRefs.value[index - 1]?.focus()
}
// Otherwise, let the browser handle the backspace naturally
// The input event will sync code.value via handleCodeInput
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
// Update both the ref and the input elements
digits.forEach((digit, index) => {
code.value[index] = digit
if (inputRefs.value[index]) {
inputRefs.value[index]!.value = digit
}
})
// Clear remaining inputs if pasted less than 6 digits
for (let i = digits.length; i < 6; i++) {
code.value[i] = ''
if (inputRefs.value[i]) {
inputRefs.value[i]!.value = ''
}
}
const focusIndex = Math.min(digits.length, 5)
nextTick(() => {
inputRefs.value[focusIndex]?.focus()
})
}
onMounted(() => {
nextTick(() => {
inputRefs.value[0]?.focus()
})
})
</script>

View File

@@ -0,0 +1,154 @@
<template>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
{{ t('profile.totp.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.description') }}
</p>
</div>
<div class="px-6 py-6">
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<!-- Feature disabled globally -->
<div v-else-if="status && !status.feature_enabled" class="flex items-center gap-4 py-4">
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">
{{ t('profile.totp.featureDisabled') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.featureDisabledHint') }}
</p>
</div>
</div>
<!-- 2FA Enabled -->
<div v-else-if="status?.enabled" class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 rounded-full bg-green-100 p-3 dark:bg-green-900/30">
<svg class="h-6 w-6 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-white">
{{ t('profile.totp.enabled') }}
</p>
<p v-if="status.enabled_at" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.enabledAt') }}: {{ formatDate(status.enabled_at) }}
</p>
</div>
</div>
<button
type="button"
class="btn btn-outline-danger"
@click="showDisableDialog = true"
>
{{ t('profile.totp.disable') }}
</button>
</div>
<!-- 2FA Not Enabled -->
<div v-else class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex-shrink-0 rounded-full bg-gray-100 p-3 dark:bg-dark-700">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<div>
<p class="font-medium text-gray-700 dark:text-gray-300">
{{ t('profile.totp.notEnabled') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.notEnabledHint') }}
</p>
</div>
</div>
<button
type="button"
class="btn btn-primary"
@click="showSetupModal = true"
>
{{ t('profile.totp.enable') }}
</button>
</div>
</div>
<!-- Setup Modal -->
<TotpSetupModal
v-if="showSetupModal"
@close="showSetupModal = false"
@success="handleSetupSuccess"
/>
<!-- Disable Dialog -->
<TotpDisableDialog
v-if="showDisableDialog"
@close="showDisableDialog = false"
@success="handleDisableSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { totpAPI } from '@/api'
import type { TotpStatus } from '@/types'
import TotpSetupModal from './TotpSetupModal.vue'
import TotpDisableDialog from './TotpDisableDialog.vue'
const { t } = useI18n()
const loading = ref(true)
const status = ref<TotpStatus | null>(null)
const showSetupModal = ref(false)
const showDisableDialog = ref(false)
const loadStatus = async () => {
loading.value = true
try {
status.value = await totpAPI.getStatus()
} catch (error) {
console.error('Failed to load TOTP status:', error)
} finally {
loading.value = false
}
}
const handleSetupSuccess = () => {
showSetupModal.value = false
loadStatus()
}
const handleDisableSuccess = () => {
showDisableDialog.value = false
loadStatus()
}
const formatDate = (timestamp: number) => {
// Backend returns Unix timestamp in seconds, convert to milliseconds
const date = new Date(timestamp * 1000)
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadStatus()
})
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></div>
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
<!-- Header -->
<div class="mb-6">
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h3 class="mt-4 text-center text-xl font-semibold text-gray-900 dark:text-white">
{{ t('profile.totp.disableTitle') }}
</h3>
<p class="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.totp.disableWarning') }}
</p>
</div>
<!-- Loading verification method -->
<div v-if="methodLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<form v-else @submit.prevent="handleDisable" class="space-y-4">
<!-- Email verification -->
<div v-if="verificationMethod === 'email'">
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
<div class="flex gap-2">
<input
v-model="form.emailCode"
type="text"
maxlength="6"
inputmode="numeric"
class="input flex-1"
:placeholder="t('profile.totp.enterEmailCode')"
/>
<button
type="button"
class="btn btn-secondary whitespace-nowrap"
:disabled="sendingCode || codeCooldown > 0"
@click="handleSendCode"
>
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
</button>
</div>
</div>
<!-- Password verification -->
<div v-else>
<label for="password" class="input-label">
{{ t('profile.currentPassword') }}
</label>
<input
id="password"
v-model="form.password"
type="password"
autocomplete="current-password"
class="input"
:placeholder="t('profile.totp.enterPassword')"
/>
</div>
<!-- Error -->
<div v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ error }}
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
<button
type="submit"
class="btn btn-danger"
:disabled="loading || !canSubmit"
>
{{ loading ? t('common.processing') : t('profile.totp.confirmDisable') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { totpAPI } from '@/api'
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const methodLoading = ref(true)
const verificationMethod = ref<'email' | 'password'>('password')
const loading = ref(false)
const error = ref('')
const sendingCode = ref(false)
const codeCooldown = ref(0)
const form = ref({
emailCode: '',
password: ''
})
const canSubmit = computed(() => {
if (verificationMethod.value === 'email') {
return form.value.emailCode.length === 6
}
return form.value.password.length > 0
})
const loadVerificationMethod = async () => {
methodLoading.value = true
try {
const method = await totpAPI.getVerificationMethod()
verificationMethod.value = method.method
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('common.error'))
emit('close')
} finally {
methodLoading.value = false
}
}
const handleSendCode = async () => {
sendingCode.value = true
try {
await totpAPI.sendVerifyCode()
appStore.showSuccess(t('profile.totp.codeSent'))
// Start cooldown
codeCooldown.value = 60
const timer = setInterval(() => {
codeCooldown.value--
if (codeCooldown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
} finally {
sendingCode.value = false
}
}
const handleDisable = async () => {
if (!canSubmit.value) return
loading.value = true
error.value = ''
try {
const request = verificationMethod.value === 'email'
? { email_code: form.value.emailCode }
: { password: form.value.password }
await totpAPI.disable(request)
appStore.showSuccess(t('profile.totp.disableSuccess'))
emit('success')
} catch (err: any) {
error.value = err.response?.data?.message || t('profile.totp.disableFailed')
} finally {
loading.value = false
}
}
onMounted(() => {
loadVerificationMethod()
})
</script>

View File

@@ -0,0 +1,400 @@
<template>
<div class="fixed inset-0 z-50 overflow-y-auto" @click.self="$emit('close')">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="$emit('close')"></div>
<div class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800">
<!-- Header -->
<div class="mb-6 text-center">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('profile.totp.setupTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ stepDescription }}
</p>
</div>
<!-- Step 0: Identity Verification -->
<div v-if="step === 0" class="space-y-6">
<!-- Loading verification method -->
<div v-if="methodLoading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
<template v-else>
<!-- Email verification -->
<div v-if="verificationMethod === 'email'" class="space-y-4">
<div>
<label class="input-label">{{ t('profile.totp.emailCode') }}</label>
<div class="flex gap-2">
<input
v-model="verifyForm.emailCode"
type="text"
maxlength="6"
inputmode="numeric"
class="input flex-1"
:placeholder="t('profile.totp.enterEmailCode')"
/>
<button
type="button"
class="btn btn-secondary whitespace-nowrap"
:disabled="sendingCode || codeCooldown > 0"
@click="handleSendCode"
>
{{ codeCooldown > 0 ? `${codeCooldown}s` : (sendingCode ? t('common.sending') : t('profile.totp.sendCode')) }}
</button>
</div>
</div>
</div>
<!-- Password verification -->
<div v-else class="space-y-4">
<div>
<label class="input-label">{{ t('profile.currentPassword') }}</label>
<input
v-model="verifyForm.password"
type="password"
autocomplete="current-password"
class="input"
:placeholder="t('profile.totp.enterPassword')"
/>
</div>
</div>
<div v-if="verifyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ verifyError }}
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!canProceedFromVerify || setupLoading"
@click="handleVerifyAndSetup"
>
{{ setupLoading ? t('common.loading') : t('common.next') }}
</button>
</div>
</template>
</div>
<!-- Step 1: Show QR Code -->
<div v-if="step === 1" class="space-y-6">
<!-- QR Code and Secret -->
<template v-if="setupData">
<div class="flex justify-center">
<div class="rounded-lg border border-gray-200 p-4 bg-white dark:border-dark-600 dark:bg-white">
<img :src="qrCodeDataUrl" alt="QR Code" class="h-48 w-48" />
</div>
</div>
<div class="text-center">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
{{ t('profile.totp.manualEntry') }}
</p>
<div class="flex items-center justify-center gap-2">
<code class="rounded bg-gray-100 px-3 py-2 font-mono text-sm dark:bg-dark-700">
{{ setupData.secret }}
</code>
<button
type="button"
class="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-dark-700"
@click="copySecret"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
</div>
</template>
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="$emit('close')">
{{ t('common.cancel') }}
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!setupData"
@click="step = 2"
>
{{ t('common.next') }}
</button>
</div>
</div>
<!-- Step 2: Verify Code -->
<div v-if="step === 2" class="space-y-6">
<form @submit.prevent="handleVerify">
<div class="mb-6">
<label class="input-label text-center block mb-3">
{{ t('profile.totp.enterCode') }}
</label>
<div class="flex justify-center gap-2">
<input
v-for="(_, index) in 6"
:key="index"
:ref="(el) => setInputRef(el, index)"
type="text"
maxlength="1"
inputmode="numeric"
pattern="[0-9]"
class="h-12 w-10 rounded-lg border border-gray-300 text-center text-lg font-semibold focus:border-primary-500 focus:ring-primary-500 dark:border-dark-600 dark:bg-dark-700"
@input="handleCodeInput($event, index)"
@keydown="handleKeydown($event, index)"
@paste="handlePaste"
/>
</div>
</div>
<div v-if="error" class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
{{ error }}
</div>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" @click="step = 1">
{{ t('common.back') }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="verifying || code.join('').length !== 6"
>
{{ verifying ? t('common.verifying') : t('profile.totp.verify') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { totpAPI } from '@/api'
import type { TotpSetupResponse } from '@/types'
import QRCode from 'qrcode'
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
// Step: 0 = verify identity, 1 = QR code, 2 = verify TOTP code
const step = ref(0)
const methodLoading = ref(true)
const verificationMethod = ref<'email' | 'password'>('password')
const verifyForm = ref({ emailCode: '', password: '' })
const verifyError = ref('')
const sendingCode = ref(false)
const codeCooldown = ref(0)
const setupLoading = ref(false)
const setupData = ref<TotpSetupResponse | null>(null)
const verifying = ref(false)
const error = ref('')
const code = ref<string[]>(['', '', '', '', '', ''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const qrCodeDataUrl = ref('')
const stepDescription = computed(() => {
switch (step.value) {
case 0:
return verificationMethod.value === 'email'
? t('profile.totp.verifyEmailFirst')
: t('profile.totp.verifyPasswordFirst')
case 1:
return t('profile.totp.setupStep1')
case 2:
return t('profile.totp.setupStep2')
default:
return ''
}
})
const canProceedFromVerify = computed(() => {
if (verificationMethod.value === 'email') {
return verifyForm.value.emailCode.length === 6
}
return verifyForm.value.password.length > 0
})
// Generate QR code as base64 when setupData changes
watch(
() => setupData.value?.qr_code_url,
async (url) => {
if (url) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(url, {
width: 200,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff'
}
})
} catch (err) {
console.error('Failed to generate QR code:', err)
}
}
},
{ immediate: true }
)
const setInputRef = (el: any, index: number) => {
inputRefs.value[index] = el as HTMLInputElement | null
}
const handleCodeInput = (event: Event, index: number) => {
const input = event.target as HTMLInputElement
const value = input.value.replace(/[^0-9]/g, '')
code.value[index] = value
if (value && index < 5) {
nextTick(() => {
inputRefs.value[index + 1]?.focus()
})
}
}
const handleKeydown = (event: KeyboardEvent, index: number) => {
if (event.key === 'Backspace') {
const input = event.target as HTMLInputElement
// If current cell is empty and not the first, move to previous cell
if (!input.value && index > 0) {
event.preventDefault()
inputRefs.value[index - 1]?.focus()
}
// Otherwise, let the browser handle the backspace naturally
// The input event will sync code.value via handleCodeInput
}
}
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault()
const pastedData = event.clipboardData?.getData('text') || ''
const digits = pastedData.replace(/[^0-9]/g, '').slice(0, 6).split('')
// Update both the ref and the input elements
digits.forEach((digit, index) => {
code.value[index] = digit
if (inputRefs.value[index]) {
inputRefs.value[index]!.value = digit
}
})
// Clear remaining inputs if pasted less than 6 digits
for (let i = digits.length; i < 6; i++) {
code.value[i] = ''
if (inputRefs.value[i]) {
inputRefs.value[i]!.value = ''
}
}
const focusIndex = Math.min(digits.length, 5)
nextTick(() => {
inputRefs.value[focusIndex]?.focus()
})
}
const copySecret = async () => {
if (setupData.value) {
try {
await navigator.clipboard.writeText(setupData.value.secret)
appStore.showSuccess(t('common.copied'))
} catch {
appStore.showError(t('common.copyFailed'))
}
}
}
const loadVerificationMethod = async () => {
methodLoading.value = true
try {
const method = await totpAPI.getVerificationMethod()
verificationMethod.value = method.method
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('common.error'))
emit('close')
} finally {
methodLoading.value = false
}
}
const handleSendCode = async () => {
sendingCode.value = true
try {
await totpAPI.sendVerifyCode()
appStore.showSuccess(t('profile.totp.codeSent'))
// Start cooldown
codeCooldown.value = 60
const timer = setInterval(() => {
codeCooldown.value--
if (codeCooldown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} catch (err: any) {
appStore.showError(err.response?.data?.message || t('profile.totp.sendCodeFailed'))
} finally {
sendingCode.value = false
}
}
const handleVerifyAndSetup = async () => {
setupLoading.value = true
verifyError.value = ''
try {
const request = verificationMethod.value === 'email'
? { email_code: verifyForm.value.emailCode }
: { password: verifyForm.value.password }
setupData.value = await totpAPI.initiateSetup(request)
step.value = 1
} catch (err: any) {
verifyError.value = err.response?.data?.message || t('profile.totp.setupFailed')
} finally {
setupLoading.value = false
}
}
const handleVerify = async () => {
const totpCode = code.value.join('')
if (totpCode.length !== 6 || !setupData.value) return
verifying.value = true
error.value = ''
try {
await totpAPI.enable({
totp_code: totpCode,
setup_token: setupData.value.setup_token
})
appStore.showSuccess(t('profile.totp.enableSuccess'))
emit('success')
} catch (err: any) {
error.value = err.response?.data?.message || t('profile.totp.verifyFailed')
code.value = ['', '', '', '', '', '']
nextTick(() => {
inputRefs.value[0]?.focus()
})
} finally {
verifying.value = false
}
}
onMounted(() => {
loadVerificationMethod()
})
</script>

View File

@@ -146,7 +146,10 @@ export default {
balance: 'Balance',
available: 'Available',
copiedToClipboard: 'Copied to clipboard',
copied: 'Copied',
copyFailed: 'Failed to copy',
verifying: 'Verifying...',
processing: 'Processing...',
contactSupport: 'Contact Support',
add: 'Add',
invalidEmail: 'Please enter a valid email address',
@@ -583,7 +586,46 @@ export default {
passwordsNotMatch: 'New passwords do not match',
passwordTooShort: 'Password must be at least 8 characters long',
passwordChangeSuccess: 'Password changed successfully',
passwordChangeFailed: 'Failed to change password'
passwordChangeFailed: 'Failed to change password',
// TOTP 2FA
totp: {
title: 'Two-Factor Authentication (2FA)',
description: 'Enhance account security with Google Authenticator or similar apps',
enabled: 'Enabled',
enabledAt: 'Enabled at',
notEnabled: 'Not Enabled',
notEnabledHint: 'Enable two-factor authentication to enhance account security',
enable: 'Enable',
disable: 'Disable',
featureDisabled: 'Feature Unavailable',
featureDisabledHint: 'Two-factor authentication has not been enabled by the administrator',
setupTitle: 'Set Up Two-Factor Authentication',
setupStep1: 'Scan the QR code below with your authenticator app',
setupStep2: 'Enter the 6-digit code from your app',
manualEntry: "Can't scan? Enter the key manually:",
enterCode: 'Enter 6-digit code',
verify: 'Verify',
setupFailed: 'Failed to get setup information',
verifyFailed: 'Invalid code, please try again',
enableSuccess: 'Two-factor authentication enabled',
disableTitle: 'Disable Two-Factor Authentication',
disableWarning: 'After disabling, you will no longer need a verification code to log in. This may reduce your account security.',
enterPassword: 'Enter your current password to confirm',
confirmDisable: 'Confirm Disable',
disableSuccess: 'Two-factor authentication disabled',
disableFailed: 'Failed to disable, please check your password',
loginTitle: 'Two-Factor Authentication',
loginHint: 'Enter the 6-digit code from your authenticator app',
loginFailed: 'Verification failed, please try again',
// New translations for email verification
verifyEmailFirst: 'Please verify your email first',
verifyPasswordFirst: 'Please verify your identity first',
emailCode: 'Email Verification Code',
enterEmailCode: 'Enter 6-digit code',
sendCode: 'Send Code',
codeSent: 'Verification code sent to your email',
sendCodeFailed: 'Failed to send verification code'
}
},
// Empty States
@@ -2774,7 +2816,11 @@ export default {
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration',
passwordReset: 'Password Reset',
passwordResetHint: 'Allow users to reset their password via email'
passwordResetHint: 'Allow users to reset their password via email',
totp: 'Two-Factor Authentication (2FA)',
totpHint: 'Allow users to use authenticator apps like Google Authenticator',
totpKeyNotConfigured:
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
},
turnstile: {
title: 'Cloudflare Turnstile',

View File

@@ -143,7 +143,10 @@ export default {
balance: '余额',
available: '可用',
copiedToClipboard: '已复制到剪贴板',
copied: '已复制',
copyFailed: '复制失败',
verifying: '验证中...',
processing: '处理中...',
contactSupport: '联系客服',
add: '添加',
invalidEmail: '请输入有效的邮箱地址',
@@ -579,7 +582,46 @@ export default {
passwordsNotMatch: '两次输入的密码不一致',
passwordTooShort: '密码至少需要 8 个字符',
passwordChangeSuccess: '密码修改成功',
passwordChangeFailed: '密码修改失败'
passwordChangeFailed: '密码修改失败',
// TOTP 2FA
totp: {
title: '双因素认证 (2FA)',
description: '使用 Google Authenticator 等应用增强账户安全',
enabled: '已启用',
enabledAt: '启用时间',
notEnabled: '未启用',
notEnabledHint: '启用双因素认证可以增强账户安全性',
enable: '启用',
disable: '禁用',
featureDisabled: '功能未开放',
featureDisabledHint: '管理员尚未开放双因素认证功能',
setupTitle: '设置双因素认证',
setupStep1: '使用认证器应用扫描下方二维码',
setupStep2: '输入应用显示的 6 位验证码',
manualEntry: '无法扫码?手动输入密钥:',
enterCode: '输入 6 位验证码',
verify: '验证',
setupFailed: '获取设置信息失败',
verifyFailed: '验证码错误,请重试',
enableSuccess: '双因素认证已启用',
disableTitle: '禁用双因素认证',
disableWarning: '禁用后,登录时将不再需要验证码。这可能会降低您的账户安全性。',
enterPassword: '请输入当前密码确认',
confirmDisable: '确认禁用',
disableSuccess: '双因素认证已禁用',
disableFailed: '禁用失败,请检查密码是否正确',
loginTitle: '双因素认证',
loginHint: '请输入您认证器应用显示的 6 位验证码',
loginFailed: '验证失败,请重试',
// New translations for email verification
verifyEmailFirst: '请先验证您的邮箱',
verifyPasswordFirst: '请先验证您的身份',
emailCode: '邮箱验证码',
enterEmailCode: '请输入 6 位验证码',
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
sendCodeFailed: '发送验证码失败'
}
},
// Empty States
@@ -2927,7 +2969,11 @@ export default {
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码'
passwordResetHint: '允许用户通过邮箱重置密码',
totp: '双因素认证 (2FA)',
totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证',
totpKeyNotConfigured:
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
},
turnstile: {
title: 'Cloudflare Turnstile',

View File

@@ -5,8 +5,8 @@
import { defineStore } from 'pinia'
import { ref, computed, readonly } from 'vue'
import { authAPI } from '@/api'
import type { User, LoginRequest, RegisterRequest } from '@/types'
import { authAPI, isTotp2FARequired, type LoginResponse } from '@/api'
import type { User, LoginRequest, RegisterRequest, AuthResponse } from '@/types'
const AUTH_TOKEN_KEY = 'auth_token'
const AUTH_USER_KEY = 'auth_user'
@@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => {
/**
* User login
* @param credentials - Login credentials (username and password)
* @returns Promise resolving to the authenticated user
* @param credentials - Login credentials (email and password)
* @returns Promise resolving to the login response (may require 2FA)
* @throws Error if login fails
*/
async function login(credentials: LoginRequest): Promise<User> {
async function login(credentials: LoginRequest): Promise<LoginResponse> {
try {
const response = await authAPI.login(credentials)
// Store token and user
token.value = response.access_token
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
// If 2FA is required, return the response without setting auth state
if (isTotp2FARequired(response)) {
return response
}
const { run_mode: _run_mode, ...userData } = response.user
user.value = userData
// Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
// Set auth state from the response
setAuthFromResponse(response)
// Start auto-refresh interval
startAutoRefresh()
return userData
return response
} catch (error) {
// Clear any partial state on error
clearAuth()
@@ -124,6 +115,47 @@ export const useAuthStore = defineStore('auth', () => {
}
}
/**
* Complete login with 2FA code
* @param tempToken - Temporary token from initial login
* @param totpCode - 6-digit TOTP code
* @returns Promise resolving to the authenticated user
* @throws Error if 2FA verification fails
*/
async function login2FA(tempToken: string, totpCode: string): Promise<User> {
try {
const response = await authAPI.login2FA({ temp_token: tempToken, totp_code: totpCode })
setAuthFromResponse(response)
return user.value!
} catch (error) {
clearAuth()
throw error
}
}
/**
* Set auth state from an AuthResponse
* Internal helper function
*/
function setAuthFromResponse(response: AuthResponse): void {
// Store token and user
token.value = response.access_token
// Extract run_mode if present
if (response.user.run_mode) {
runMode.value = response.user.run_mode
}
const { run_mode: _run_mode, ...userData } = response.user
user.value = userData
// Persist to localStorage
localStorage.setItem(AUTH_TOKEN_KEY, response.access_token)
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(userData))
// Start auto-refresh interval
startAutoRefresh()
}
/**
* User registration
* @param userData - Registration data (username, email, password)
@@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => {
// Actions
login,
login2FA,
register,
setToken,
logout,

View File

@@ -1108,3 +1108,52 @@ export interface UpdatePromoCodeRequest {
expires_at?: number | null
notes?: string
}
// ==================== TOTP (2FA) Types ====================
export interface TotpStatus {
enabled: boolean
enabled_at: number | null // Unix timestamp in seconds
feature_enabled: boolean
}
export interface TotpSetupRequest {
email_code?: string
password?: string
}
export interface TotpSetupResponse {
secret: string
qr_code_url: string
setup_token: string
countdown: number
}
export interface TotpEnableRequest {
totp_code: string
setup_token: string
}
export interface TotpEnableResponse {
success: boolean
}
export interface TotpDisableRequest {
email_code?: string
password?: string
}
export interface TotpVerificationMethod {
method: 'email' | 'password'
}
export interface TotpLoginResponse {
requires_2fa: boolean
temp_token?: string
user_email_masked?: string
}
export interface TotpLogin2FARequest {
temp_token: string
totp_code: string
}

View File

@@ -354,6 +354,31 @@
</div>
<Toggle v-model="form.password_reset_enabled" />
</div>
<!-- TOTP 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.registration.totp')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.totpHint') }}
</p>
<!-- Warning when encryption key not configured -->
<p
v-if="!form.totp_encryption_key_configured"
class="mt-2 text-sm text-amber-600 dark:text-amber-400"
>
{{ t('admin.settings.registration.totpKeyNotConfigured') }}
</p>
</div>
<Toggle
v-model="form.totp_enabled"
:disabled="!form.totp_encryption_key_configured"
/>
</div>
</div>
</div>
@@ -1046,6 +1071,8 @@ const form = reactive<SettingsForm>({
email_verify_enabled: false,
promo_code_enabled: true,
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
default_balance: 0,
default_concurrency: 1,
site_name: 'Sub2API',
@@ -1170,6 +1197,7 @@ async function saveSettings() {
email_verify_enabled: form.email_verify_enabled,
promo_code_enabled: form.promo_code_enabled,
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,
default_concurrency: form.default_concurrency,
site_name: form.site_name,

View File

@@ -163,6 +163,16 @@
</p>
</template>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if="show2FAModal"
ref="totpModalRef"
:temp-token="totpTempToken"
:user-email-masked="totpUserEmailMasked"
@verify="handle2FAVerify"
@cancel="handle2FACancel"
/>
</template>
<script setup lang="ts">
@@ -171,10 +181,12 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings } from '@/api/auth'
import { getPublicSettings, isTotp2FARequired } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
const { t } = useI18n()
@@ -200,6 +212,12 @@ const passwordResetEnabled = ref<boolean>(false)
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
// 2FA state
const show2FAModal = ref<boolean>(false)
const totpTempToken = ref<string>('')
const totpUserEmailMasked = ref<string>('')
const totpModalRef = ref<InstanceType<typeof TotpLoginModal> | null>(null)
const formData = reactive({
email: '',
password: ''
@@ -302,12 +320,22 @@ async function handleLogin(): Promise<void> {
try {
// Call auth store login
await authStore.login({
const response = await authStore.login({
email: formData.email,
password: formData.password,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
// Check if 2FA is required
if (isTotp2FARequired(response)) {
const totpResponse = response as TotpLoginResponse
totpTempToken.value = totpResponse.temp_token || ''
totpUserEmailMasked.value = totpResponse.user_email_masked || ''
show2FAModal.value = true
isLoading.value = false
return
}
// Show success toast
appStore.showSuccess(t('auth.loginSuccess'))
@@ -338,6 +366,40 @@ async function handleLogin(): Promise<void> {
isLoading.value = false
}
}
// ==================== 2FA Handlers ====================
async function handle2FAVerify(code: string): Promise<void> {
if (totpModalRef.value) {
totpModalRef.value.setVerifying(true)
}
try {
await authStore.login2FA(totpTempToken.value, code)
// Close modal and show success
show2FAModal.value = false
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
const redirectTo = (router.currentRoute.value.query.redirect as string) || '/dashboard'
await router.push(redirectTo)
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { message?: string } } }
const message = err.response?.data?.message || err.message || t('profile.totp.loginFailed')
if (totpModalRef.value) {
totpModalRef.value.setError(message)
totpModalRef.value.setVerifying(false)
}
}
}
function handle2FACancel(): void {
show2FAModal.value = false
totpTempToken.value = ''
totpUserEmailMasked.value = ''
}
</script>
<style scoped>

View File

@@ -15,6 +15,7 @@
</div>
<ProfileEditForm :initial-username="user?.username || ''" />
<ProfilePasswordForm />
<ProfileTotpCard />
</div>
</AppLayout>
</template>
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
import ProfileTotpCard from '@/components/user/profile/ProfileTotpCard.vue'
import { Icon } from '@/components/icons'
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)