Commit a161fcc8 authored by cyhhao's avatar cyhhao
Browse files

Merge branch 'main' of github.com:Wei-Shaw/sub2api

parents 65e69738 e32c5f53
......@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
- `columns: Column[]` - Array of column definitions with key, label, sortable, and formatter
- `data: any[]` - Array of data objects to display
- `loading?: boolean` - Show loading skeleton
- `defaultSortKey?: string` - Default sort key (only used if no persisted sort state)
- `defaultSortOrder?: 'asc' | 'desc'` - Default sort order (default: `asc`)
- `sortStorageKey?: string` - Persist sort state (key + order) to localStorage
- `rowKey?: string | (row: any) => string | number` - Row key field or resolver (defaults to `row.id`, falls back to index)
**Slots:**
......
<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>
<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>
<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>
......@@ -17,6 +17,7 @@ export interface OAuthState {
export interface TokenInfo {
org_uuid?: string
account_uuid?: string
email_address?: string
[key: string]: unknown
}
......@@ -160,6 +161,9 @@ export function useAccountOAuth() {
if (tokenInfo.account_uuid) {
extra.account_uuid = tokenInfo.account_uuid
}
if (tokenInfo.email_address) {
extra.email_address = tokenInfo.email_address
}
return Object.keys(extra).length > 0 ? extra : undefined
}
......
......@@ -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',
......@@ -169,7 +172,13 @@ export default {
justNow: 'Just now',
minutesAgo: '{n}m ago',
hoursAgo: '{n}h ago',
daysAgo: '{n}d ago'
daysAgo: '{n}d ago',
countdown: {
daysHours: '{d}d {h}h',
hoursMinutes: '{h}h {m}m',
minutes: '{m}m',
withSuffix: '{time} to lift'
}
}
},
......@@ -265,7 +274,36 @@ export default {
code: 'Code',
state: 'State',
fullUrl: 'Full URL'
}
},
// Forgot password
forgotPassword: 'Forgot password?',
forgotPasswordTitle: 'Reset Your Password',
forgotPasswordHint: 'Enter your email address and we will send you a link to reset your password.',
sendResetLink: 'Send Reset Link',
sendingResetLink: 'Sending...',
sendResetLinkFailed: 'Failed to send reset link. Please try again.',
resetEmailSent: 'Reset Link Sent',
resetEmailSentHint: 'If an account exists with this email, you will receive a password reset link shortly. Please check your inbox and spam folder.',
backToLogin: 'Back to Login',
rememberedPassword: 'Remembered your password?',
// Reset password
resetPasswordTitle: 'Set New Password',
resetPasswordHint: 'Enter your new password below.',
newPassword: 'New Password',
newPasswordPlaceholder: 'Enter your new password',
confirmPassword: 'Confirm Password',
confirmPasswordPlaceholder: 'Confirm your new password',
confirmPasswordRequired: 'Please confirm your password',
passwordsDoNotMatch: 'Passwords do not match',
resetPassword: 'Reset Password',
resettingPassword: 'Resetting...',
resetPasswordFailed: 'Failed to reset password. Please try again.',
passwordResetSuccess: 'Password Reset Successful',
passwordResetSuccessHint: 'Your password has been reset. You can now sign in with your new password.',
invalidResetLink: 'Invalid Reset Link',
invalidResetLinkHint: 'This password reset link is invalid or has expired. Please request a new one.',
requestNewResetLink: 'Request New Reset Link',
invalidOrExpiredToken: 'The password reset link is invalid or has expired. Please request a new one.'
},
// Dashboard
......@@ -548,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
......@@ -1022,6 +1099,13 @@ export default {
title: 'Account Management',
description: 'Manage AI platform accounts and credentials',
createAccount: 'Create Account',
autoRefresh: 'Auto Refresh',
enableAutoRefresh: 'Enable auto refresh',
refreshInterval5s: '5 seconds',
refreshInterval10s: '10 seconds',
refreshInterval15s: '15 seconds',
refreshInterval30s: '30 seconds',
autoRefreshCountdown: 'Auto refresh: {seconds}s',
syncFromCrs: 'Sync from CRS',
syncFromCrsTitle: 'Sync Accounts from CRS',
syncFromCrsDesc:
......@@ -1083,6 +1167,8 @@ export default {
cooldown: 'Cooldown',
paused: 'Paused',
limited: 'Limited',
rateLimited: 'Rate Limited',
overloaded: 'Overloaded',
tempUnschedulable: 'Temp Unschedulable',
rateLimitedUntil: 'Rate limited until {time}',
overloadedUntil: 'Overloaded until {time}',
......@@ -2728,7 +2814,13 @@ export default {
emailVerification: 'Email Verification',
emailVerificationHint: 'Require email verification for new registrations',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration'
promoCodeHint: 'Allow users to use promo codes during registration',
passwordReset: 'Password Reset',
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',
......
......@@ -143,7 +143,10 @@ export default {
balance: '余额',
available: '可用',
copiedToClipboard: '已复制到剪贴板',
copied: '已复制',
copyFailed: '复制失败',
verifying: '验证中...',
processing: '处理中...',
contactSupport: '联系客服',
add: '添加',
invalidEmail: '请输入有效的邮箱地址',
......@@ -166,7 +169,13 @@ export default {
justNow: '刚刚',
minutesAgo: '{n}分钟前',
hoursAgo: '{n}小时前',
daysAgo: '{n}天前'
daysAgo: '{n}天前',
countdown: {
daysHours: '{d}d {h}h',
hoursMinutes: '{h}h {m}m',
minutes: '{m}m',
withSuffix: '{time} 后解除'
}
}
},
......@@ -262,7 +271,36 @@ export default {
code: '授权码',
state: '状态',
fullUrl: '完整URL'
}
},
// 忘记密码
forgotPassword: '忘记密码?',
forgotPasswordTitle: '重置密码',
forgotPasswordHint: '输入您的邮箱地址,我们将向您发送密码重置链接。',
sendResetLink: '发送重置链接',
sendingResetLink: '发送中...',
sendResetLinkFailed: '发送重置链接失败,请重试。',
resetEmailSent: '重置链接已发送',
resetEmailSentHint: '如果该邮箱已注册,您将很快收到密码重置链接。请检查您的收件箱和垃圾邮件文件夹。',
backToLogin: '返回登录',
rememberedPassword: '想起密码了?',
// 重置密码
resetPasswordTitle: '设置新密码',
resetPasswordHint: '请在下方输入您的新密码。',
newPassword: '新密码',
newPasswordPlaceholder: '输入新密码',
confirmPassword: '确认密码',
confirmPasswordPlaceholder: '再次输入新密码',
confirmPasswordRequired: '请确认您的密码',
passwordsDoNotMatch: '两次输入的密码不一致',
resetPassword: '重置密码',
resettingPassword: '重置中...',
resetPasswordFailed: '重置密码失败,请重试。',
passwordResetSuccess: '密码重置成功',
passwordResetSuccessHint: '您的密码已重置。现在可以使用新密码登录。',
invalidResetLink: '无效的重置链接',
invalidResetLinkHint: '此密码重置链接无效或已过期。请重新请求一个新链接。',
requestNewResetLink: '请求新的重置链接',
invalidOrExpiredToken: '密码重置链接无效或已过期。请重新请求一个新链接。'
},
// Dashboard
......@@ -544,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
......@@ -1096,6 +1173,13 @@ export default {
title: '账号管理',
description: '管理 AI 平台账号和 Cookie',
createAccount: '添加账号',
autoRefresh: '自动刷新',
enableAutoRefresh: '启用自动刷新',
refreshInterval5s: '5 秒',
refreshInterval10s: '10 秒',
refreshInterval15s: '15 秒',
refreshInterval30s: '30 秒',
autoRefreshCountdown: '自动刷新:{seconds}s',
syncFromCrs: '从 CRS 同步',
syncFromCrsTitle: '从 CRS 同步账号',
syncFromCrsDesc:
......@@ -1205,6 +1289,8 @@ export default {
cooldown: '冷却中',
paused: '暂停',
limited: '限流',
rateLimited: '限流中',
overloaded: '过载中',
tempUnschedulable: '临时不可调度',
rateLimitedUntil: '限流中,重置时间:{time}',
overloadedUntil: '负载过重,重置时间:{time}',
......@@ -2881,7 +2967,13 @@ export default {
emailVerification: '邮箱验证',
emailVerificationHint: '新用户注册时需要验证邮箱',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码'
promoCodeHint: '允许用户在注册时使用优惠码',
passwordReset: '忘记密码',
passwordResetHint: '允许用户通过邮箱重置密码',
totp: '双因素认证 (2FA)',
totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证',
totpKeyNotConfigured:
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
},
turnstile: {
title: 'Cloudflare Turnstile',
......
......@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
title: 'LinuxDo OAuth Callback'
}
},
{
path: '/forgot-password',
name: 'ForgotPassword',
component: () => import('@/views/auth/ForgotPasswordView.vue'),
meta: {
requiresAuth: false,
title: 'Forgot Password'
}
},
{
path: '/reset-password',
name: 'ResetPassword',
component: () => import('@/views/auth/ResetPasswordView.vue'),
meta: {
requiresAuth: false,
title: 'Reset Password'
}
},
// ==================== User Routes ====================
{
......
......@@ -313,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
registration_enabled: false,
email_verify_enabled: false,
promo_code_enabled: true,
password_reset_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: siteName.value,
......
......@@ -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))
// Start auto-refresh interval
startAutoRefresh()
// Set auth state from the response
setAuthFromResponse(response)
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,
......
......@@ -71,6 +71,7 @@ export interface PublicSettings {
registration_enabled: boolean
email_verify_enabled: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
turnstile_enabled: boolean
turnstile_site_key: string
site_name: string
......@@ -1107,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
}
......@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toString()
}
/**
* 格式化倒计时(从现在到目标时间的剩余时间)
* @param targetDate 目标日期字符串或 Date 对象
* @returns 倒计时字符串,如 "2h 41m", "3d 5h", "15m"
*/
export function formatCountdown(targetDate: string | Date | null | undefined): string | null {
if (!targetDate) return null
const now = new Date()
const target = new Date(targetDate)
const diffMs = target.getTime() - now.getTime()
// 如果目标时间已过或无效
if (diffMs <= 0 || isNaN(diffMs)) return null
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
const remainingHours = diffHours % 24
const remainingMins = diffMins % 60
if (diffDays > 0) {
// 超过1天:显示 "Xd Yh"
return i18n.global.t('common.time.countdown.daysHours', { d: diffDays, h: remainingHours })
}
if (diffHours > 0) {
// 小于1天:显示 "Xh Ym"
return i18n.global.t('common.time.countdown.hoursMinutes', { h: diffHours, m: remainingMins })
}
// 小于1小时:显示 "Ym"
return i18n.global.t('common.time.countdown.minutes', { m: diffMins })
}
/**
* 格式化倒计时并带后缀(如 "2h 41m 后解除")
* @param targetDate 目标日期字符串或 Date 对象
* @returns 完整的倒计时字符串,如 "2h 41m to lift", "2小时41分钟后解除"
*/
export function formatCountdownWithSuffix(targetDate: string | Date | null | undefined): string | null {
const countdown = formatCountdown(targetDate)
if (!countdown) return null
return i18n.global.t('common.time.countdown.withSuffix', { time: countdown })
}
......@@ -17,10 +17,58 @@
@create="showCreate = true"
>
<template #after>
<!-- Auto Refresh Dropdown -->
<div class="relative" ref="autoRefreshDropdownRef">
<button
@click="
showAutoRefreshDropdown = !showAutoRefreshDropdown;
showColumnDropdown = false
"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.accounts.autoRefresh')"
>
<Icon name="refresh" size="sm" :class="[autoRefreshEnabled ? 'animate-spin' : '']" />
<span class="hidden md:inline">
{{
autoRefreshEnabled
? t('admin.accounts.autoRefreshCountdown', { seconds: autoRefreshCountdown })
: t('admin.accounts.autoRefresh')
}}
</span>
</button>
<div
v-if="showAutoRefreshDropdown"
class="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
<div class="p-2">
<button
@click="setAutoRefreshEnabled(!autoRefreshEnabled)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ t('admin.accounts.enableAutoRefresh') }}</span>
<Icon v-if="autoRefreshEnabled" name="check" size="sm" class="text-primary-500" />
</button>
<div class="my-1 border-t border-gray-100 dark:border-gray-700"></div>
<button
v-for="sec in autoRefreshIntervals"
:key="sec"
@click="setAutoRefreshInterval(sec)"
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700"
>
<span>{{ autoRefreshIntervalLabel(sec) }}</span>
<Icon v-if="autoRefreshIntervalSeconds === sec" name="check" size="sm" class="text-primary-500" />
</button>
</div>
</div>
</div>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@click="showColumnDropdown = !showColumnDropdown"
@click="
showColumnDropdown = !showColumnDropdown;
showAutoRefreshDropdown = false
"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.columnSettings')"
>
......@@ -53,12 +101,29 @@
</template>
<template #table>
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @edit="showBulkEdit = true" @clear="selIds = []" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<DataTable :columns="cols" :data="accounts" :loading="loading" row-key="id">
<DataTable
:columns="cols"
:data="accounts"
:loading="loading"
row-key="id"
default-sort-key="name"
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
>
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
<template #cell-name="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
<span
v-if="row.extra?.email_address"
class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]"
:title="row.extra.email_address"
>
{{ row.extra.email_address }}
</span>
</div>
</template>
<template #cell-notes="{ value }">
<span v-if="value" :title="value" class="block max-w-xs truncate text-sm text-gray-600 dark:text-gray-300">{{ value }}</span>
......@@ -161,6 +226,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
......@@ -221,6 +287,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
const DEFAULT_HIDDEN_COLUMNS = ['proxy', 'notes', 'priority', 'rate_multiplier']
const HIDDEN_COLUMNS_KEY = 'account-hidden-columns'
// Sorting settings
const ACCOUNT_SORT_STORAGE_KEY = 'account-table-sort'
// Auto refresh settings
const showAutoRefreshDropdown = ref(false)
const autoRefreshDropdownRef = ref<HTMLElement | null>(null)
const AUTO_REFRESH_STORAGE_KEY = 'account-auto-refresh'
const autoRefreshIntervals = [5, 10, 15, 30] as const
const autoRefreshEnabled = ref(false)
const autoRefreshIntervalSeconds = ref<(typeof autoRefreshIntervals)[number]>(30)
const autoRefreshCountdown = ref(0)
const autoRefreshIntervalLabel = (sec: number) => {
if (sec === 5) return t('admin.accounts.refreshInterval5s')
if (sec === 10) return t('admin.accounts.refreshInterval10s')
if (sec === 15) return t('admin.accounts.refreshInterval15s')
if (sec === 30) return t('admin.accounts.refreshInterval30s')
return `${sec}s`
}
const loadSavedColumns = () => {
try {
const saved = localStorage.getItem(HIDDEN_COLUMNS_KEY)
......@@ -244,6 +330,60 @@ const saveColumnsToStorage = () => {
}
}
const loadSavedAutoRefresh = () => {
try {
const saved = localStorage.getItem(AUTO_REFRESH_STORAGE_KEY)
if (!saved) return
const parsed = JSON.parse(saved) as { enabled?: boolean; interval_seconds?: number }
autoRefreshEnabled.value = parsed.enabled === true
const interval = Number(parsed.interval_seconds)
if (autoRefreshIntervals.includes(interval as any)) {
autoRefreshIntervalSeconds.value = interval as any
}
} catch (e) {
console.error('Failed to load saved auto refresh settings:', e)
}
}
const saveAutoRefreshToStorage = () => {
try {
localStorage.setItem(
AUTO_REFRESH_STORAGE_KEY,
JSON.stringify({
enabled: autoRefreshEnabled.value,
interval_seconds: autoRefreshIntervalSeconds.value
})
)
} catch (e) {
console.error('Failed to save auto refresh settings:', e)
}
}
if (typeof window !== 'undefined') {
loadSavedColumns()
loadSavedAutoRefresh()
}
const setAutoRefreshEnabled = (enabled: boolean) => {
autoRefreshEnabled.value = enabled
saveAutoRefreshToStorage()
if (enabled) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
resumeAutoRefresh()
} else {
pauseAutoRefresh()
autoRefreshCountdown.value = 0
}
}
const setAutoRefreshInterval = (seconds: (typeof autoRefreshIntervals)[number]) => {
autoRefreshIntervalSeconds.value = seconds
saveAutoRefreshToStorage()
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = seconds
}
}
const toggleColumn = (key: string) => {
if (hiddenColumns.has(key)) {
hiddenColumns.delete(key)
......@@ -260,6 +400,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
initialParams: { platform: '', type: '', status: '', search: '' }
})
const isAnyModalOpen = computed(() => {
return (
showCreate.value ||
showEdit.value ||
showSync.value ||
showBulkEdit.value ||
showTempUnsched.value ||
showDeleteDialog.value ||
showReAuth.value ||
showTest.value ||
showStats.value
)
})
const { pause: pauseAutoRefresh, resume: resumeAutoRefresh } = useIntervalFn(
async () => {
if (!autoRefreshEnabled.value) return
if (document.hidden) return
if (loading.value) return
if (isAnyModalOpen.value) return
if (menu.show) return
if (autoRefreshCountdown.value <= 0) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
try {
await load()
} catch (e) {
console.error('Auto refresh failed:', e)
}
return
}
autoRefreshCountdown.value -= 1
},
1000,
{ immediate: false }
)
// All available columns
const allColumns = computed(() => {
const c = [
......@@ -512,10 +690,12 @@ const handleClickOutside = (event: MouseEvent) => {
if (columnDropdownRef.value && !columnDropdownRef.value.contains(target)) {
showColumnDropdown.value = false
}
if (autoRefreshDropdownRef.value && !autoRefreshDropdownRef.value.contains(target)) {
showAutoRefreshDropdown.value = false
}
}
onMounted(async () => {
loadSavedColumns()
load()
try {
const [p, g] = await Promise.all([adminAPI.proxies.getAll(), adminAPI.groups.getAll()])
......@@ -526,6 +706,13 @@ onMounted(async () => {
}
window.addEventListener('scroll', handleScroll, true)
document.addEventListener('click', handleClickOutside)
if (autoRefreshEnabled.value) {
autoRefreshCountdown.value = autoRefreshIntervalSeconds.value
resumeAutoRefresh()
} else {
pauseAutoRefresh()
}
})
onUnmounted(() => {
......
......@@ -338,6 +338,47 @@
</div>
<Toggle v-model="form.promo_code_enabled" />
</div>
<!-- Password Reset - Only show when email verification is enabled -->
<div
v-if="form.email_verify_enabled"
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.passwordReset')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.registration.passwordResetHint') }}
</p>
</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>
......@@ -1029,6 +1070,9 @@ const form = reactive<SettingsForm>({
registration_enabled: true,
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',
......@@ -1152,6 +1196,8 @@ async function saveSettings() {
registration_enabled: form.registration_enabled,
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,
......
......@@ -154,7 +154,13 @@
<!-- Subscriptions Table -->
<template #table>
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
<DataTable
:columns="columns"
:data="subscriptions"
:loading="loading"
:server-side-sort="true"
@sort="handleSort"
>
<template #cell-user="{ row }">
<div class="flex items-center gap-2">
<div
......@@ -357,7 +363,7 @@
<template #cell-actions="{ row }">
<div class="flex items-center gap-1">
<button
v-if="row.status === 'active'"
v-if="row.status === 'active' || row.status === 'expired'"
@click="handleExtend(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
>
......@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
label: userColumnMode.value === 'email'
? t('admin.subscriptions.columns.user')
: t('admin.users.columns.username'),
sortable: true
sortable: false
},
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
......@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({
status: '',
status: 'active',
group_id: '',
user_id: null as number | null
})
// Sorting state
const sortState = reactive({
sort_by: 'created_at',
sort_order: 'desc' as 'asc' | 'desc'
})
const pagination = reactive({
page: 1,
page_size: 20,
......@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
{
status: (filters.status as any) || undefined,
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
user_id: filters.user_id || undefined
user_id: filters.user_id || undefined,
sort_by: sortState.sort_by,
sort_order: sortState.sort_order
},
{
signal
......@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadSubscriptions()
}
const handleSort = (key: string, order: 'asc' | 'desc') => {
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
loadSubscriptions()
}
const closeAssignModal = () => {
showAssignModal.value = false
assignForm.user_id = null
......@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
const handleExtendSubscription = async () => {
if (!extendingSubscription.value) return
// 前端验证:调整后剩余天数必须 > 0
// 前端验证:调整后的过期时间必须在未来
if (extendingSubscription.value.expires_at) {
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
const newDaysRemaining = currentDaysRemaining + extendForm.days
if (newDaysRemaining <= 0) {
const expiresAt = new Date(extendingSubscription.value.expires_at)
const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
if (newExpiresAt <= new Date()) {
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
return
}
......
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.forgotPasswordTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.forgotPasswordHint') }}
</p>
</div>
<!-- Success State -->
<div v-if="isSubmitted" class="space-y-6">
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
{{ t('auth.resetEmailSent') }}
</h3>
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
{{ t('auth.resetEmailSentHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/login"
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="arrowLeft" size="sm" />
{{ t('auth.backToLogin') }}
</router-link>
</div>
</div>
<!-- Form State -->
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="mail" size="md" class="mr-2" />
{{ isLoading ? t('auth.sendingResetLink') : t('auth.sendResetLink') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.rememberedPassword') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAppStore } from '@/stores'
import { getPublicSettings, forgotPassword } from '@/api/auth'
const { t } = useI18n()
// ==================== Stores ====================
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSubmitted = ref<boolean>(false)
const errorMessage = ref<string>('')
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
const formData = reactive({
email: ''
})
const errors = reactive({
email: '',
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
} catch (error) {
console.error('Failed to load public settings:', error)
}
})
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
function validateForm(): boolean {
errors.email = ''
errors.turnstile = ''
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleSubmit(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
await forgotPassword({
email: formData.email,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
isSubmitted.value = true
appStore.showSuccess(t('auth.resetEmailSent'))
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset()
turnstileToken.value = ''
}
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.sendResetLinkFailed')
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
......@@ -72,9 +72,19 @@
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<div class="mt-1 flex items-center justify-between">
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
<span v-else></span>
<router-link
v-if="passwordResetEnabled"
to="/forgot-password"
class="text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.forgotPassword') }}
</router-link>
</div>
</div>
<!-- Turnstile Widget -->
......@@ -153,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">
......@@ -161,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()
......@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
const linuxdoOAuthEnabled = ref<boolean>(false)
const passwordResetEnabled = ref<boolean>(false)
// Turnstile
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: ''
......@@ -216,6 +245,7 @@ onMounted(async () => {
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
passwordResetEnabled.value = settings.password_reset_enabled
} catch (error) {
console.error('Failed to load public settings:', error)
}
......@@ -290,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'))
......@@ -326,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>
......
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.resetPasswordTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.resetPasswordHint') }}
</p>
</div>
<!-- Invalid Link State -->
<div v-if="isInvalidLink" class="space-y-6">
<div class="rounded-xl border border-red-200 bg-red-50 p-6 dark:border-red-800/50 dark:bg-red-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-800/50">
<Icon name="exclamationCircle" size="lg" class="text-red-600 dark:text-red-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
{{ t('auth.invalidResetLink') }}
</h3>
<p class="mt-2 text-sm text-red-700 dark:text-red-300">
{{ t('auth.invalidResetLinkHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/forgot-password"
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.requestNewResetLink') }}
</router-link>
</div>
</div>
<!-- Success State -->
<div v-else-if="isSuccess" class="space-y-6">
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
{{ t('auth.passwordResetSuccess') }}
</h3>
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
{{ t('auth.passwordResetSuccessHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/login"
class="btn btn-primary inline-flex items-center gap-2"
>
<Icon name="login" size="md" />
{{ t('auth.signIn') }}
</router-link>
</div>
</div>
<!-- Form State -->
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<!-- Email (readonly) -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
:value="email"
type="email"
readonly
disabled
class="input pl-11 bg-gray-50 dark:bg-dark-700"
/>
</div>
</div>
<!-- New Password Input -->
<div>
<label for="password" class="input-label">
{{ t('auth.newPassword') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="password"
v-model="formData.password"
:type="showPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.password }"
:placeholder="t('auth.newPasswordPlaceholder')"
/>
<button
type="button"
@click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.password" class="input-error-text">
{{ errors.password }}
</p>
</div>
<!-- Confirm Password Input -->
<div>
<label for="confirmPassword" class="input-label">
{{ t('auth.confirmPassword') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="confirmPassword"
v-model="formData.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
required
autocomplete="new-password"
:disabled="isLoading"
class="input pl-11 pr-11"
:class="{ 'input-error': errors.confirmPassword }"
:placeholder="t('auth.confirmPasswordPlaceholder')"
/>
<button
type="button"
@click="showConfirmPassword = !showConfirmPassword"
class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300"
>
<Icon v-if="showConfirmPassword" name="eyeOff" size="md" />
<Icon v-else name="eye" size="md" />
</button>
</div>
<p v-if="errors.confirmPassword" class="input-error-text">
{{ errors.confirmPassword }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="checkCircle" size="md" class="mr-2" />
{{ isLoading ? t('auth.resettingPassword') : t('auth.resetPassword') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.rememberedPassword') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { resetPassword } from '@/api/auth'
const { t } = useI18n()
// ==================== Router & Stores ====================
const route = useRoute()
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSuccess = ref<boolean>(false)
const errorMessage = ref<string>('')
const showPassword = ref<boolean>(false)
const showConfirmPassword = ref<boolean>(false)
// URL parameters
const email = ref<string>('')
const token = ref<string>('')
const formData = reactive({
password: '',
confirmPassword: ''
})
const errors = reactive({
password: '',
confirmPassword: ''
})
// Check if the reset link is valid (has email and token)
const isInvalidLink = computed(() => !email.value || !token.value)
// ==================== Lifecycle ====================
onMounted(() => {
// Get email and token from URL query parameters
email.value = (route.query.email as string) || ''
token.value = (route.query.token as string) || ''
})
// ==================== Validation ====================
function validateForm(): boolean {
errors.password = ''
errors.confirmPassword = ''
let isValid = true
// Password validation
if (!formData.password) {
errors.password = t('auth.passwordRequired')
isValid = false
} else if (formData.password.length < 6) {
errors.password = t('auth.passwordMinLength')
isValid = false
}
// Confirm password validation
if (!formData.confirmPassword) {
errors.confirmPassword = t('auth.confirmPasswordRequired')
isValid = false
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = t('auth.passwordsDoNotMatch')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleSubmit(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
await resetPassword({
email: email.value,
token: token.value,
new_password: formData.password
})
isSuccess.value = true
appStore.showSuccess(t('auth.passwordResetSuccess'))
} catch (error: unknown) {
const err = error as { message?: string; response?: { data?: { detail?: string; code?: string } } }
// Check for invalid/expired token error
if (err.response?.data?.code === 'INVALID_RESET_TOKEN') {
errorMessage.value = t('auth.invalidOrExpiredToken')
} else if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.resetPasswordFailed')
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
......@@ -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)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment