Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
a161fcc8
Commit
a161fcc8
authored
Jan 26, 2026
by
cyhhao
Browse files
Merge branch 'main' of github.com:Wei-Shaw/sub2api
parents
65e69738
e32c5f53
Changes
119
Hide whitespace changes
Inline
Side-by-side
frontend/src/components/common/README.md
View file @
a161fcc8
...
@@ -13,6 +13,9 @@ A generic data table component with sorting, loading states, and custom cell ren
...
@@ -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
-
`columns: Column[]`
- Array of column definitions with key, label, sortable, and formatter
-
`data: any[]`
- Array of data objects to display
-
`data: any[]`
- Array of data objects to display
-
`loading?: boolean`
- Show loading skeleton
-
`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)
-
`rowKey?: string | (row: any) => string | number`
- Row key field or resolver (defaults to
`row.id`
, falls back to index)
**Slots:**
**Slots:**
...
...
frontend/src/components/user/profile/ProfileTotpCard.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/components/user/profile/TotpDisableDialog.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/components/user/profile/TotpSetupModal.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/composables/useAccountOAuth.ts
View file @
a161fcc8
...
@@ -17,6 +17,7 @@ export interface OAuthState {
...
@@ -17,6 +17,7 @@ export interface OAuthState {
export
interface
TokenInfo
{
export
interface
TokenInfo
{
org_uuid
?:
string
org_uuid
?:
string
account_uuid
?:
string
account_uuid
?:
string
email_address
?:
string
[
key
:
string
]:
unknown
[
key
:
string
]:
unknown
}
}
...
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
...
@@ -160,6 +161,9 @@ export function useAccountOAuth() {
if
(
tokenInfo
.
account_uuid
)
{
if
(
tokenInfo
.
account_uuid
)
{
extra
.
account_uuid
=
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
return
Object
.
keys
(
extra
).
length
>
0
?
extra
:
undefined
}
}
...
...
frontend/src/i18n/locales/en.ts
View file @
a161fcc8
...
@@ -146,7 +146,10 @@ export default {
...
@@ -146,7 +146,10 @@ export default {
balance
:
'
Balance
'
,
balance
:
'
Balance
'
,
available
:
'
Available
'
,
available
:
'
Available
'
,
copiedToClipboard
:
'
Copied to clipboard
'
,
copiedToClipboard
:
'
Copied to clipboard
'
,
copied
:
'
Copied
'
,
copyFailed
:
'
Failed to copy
'
,
copyFailed
:
'
Failed to copy
'
,
verifying
:
'
Verifying...
'
,
processing
:
'
Processing...
'
,
contactSupport
:
'
Contact Support
'
,
contactSupport
:
'
Contact Support
'
,
add
:
'
Add
'
,
add
:
'
Add
'
,
invalidEmail
:
'
Please enter a valid email address
'
,
invalidEmail
:
'
Please enter a valid email address
'
,
...
@@ -169,7 +172,13 @@ export default {
...
@@ -169,7 +172,13 @@ export default {
justNow
:
'
Just now
'
,
justNow
:
'
Just now
'
,
minutesAgo
:
'
{n}m ago
'
,
minutesAgo
:
'
{n}m ago
'
,
hoursAgo
:
'
{n}h 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 {
...
@@ -265,7 +274,36 @@ export default {
code
:
'
Code
'
,
code
:
'
Code
'
,
state
:
'
State
'
,
state
:
'
State
'
,
fullUrl
:
'
Full URL
'
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
// Dashboard
...
@@ -548,7 +586,46 @@ export default {
...
@@ -548,7 +586,46 @@ export default {
passwordsNotMatch
:
'
New passwords do not match
'
,
passwordsNotMatch
:
'
New passwords do not match
'
,
passwordTooShort
:
'
Password must be at least 8 characters long
'
,
passwordTooShort
:
'
Password must be at least 8 characters long
'
,
passwordChangeSuccess
:
'
Password changed successfully
'
,
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
// Empty States
...
@@ -1022,6 +1099,13 @@ export default {
...
@@ -1022,6 +1099,13 @@ export default {
title
:
'
Account Management
'
,
title
:
'
Account Management
'
,
description
:
'
Manage AI platform accounts and credentials
'
,
description
:
'
Manage AI platform accounts and credentials
'
,
createAccount
:
'
Create Account
'
,
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
'
,
syncFromCrs
:
'
Sync from CRS
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsTitle
:
'
Sync Accounts from CRS
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
...
@@ -1083,6 +1167,8 @@ export default {
...
@@ -1083,6 +1167,8 @@ export default {
cooldown
:
'
Cooldown
'
,
cooldown
:
'
Cooldown
'
,
paused
:
'
Paused
'
,
paused
:
'
Paused
'
,
limited
:
'
Limited
'
,
limited
:
'
Limited
'
,
rateLimited
:
'
Rate Limited
'
,
overloaded
:
'
Overloaded
'
,
tempUnschedulable
:
'
Temp Unschedulable
'
,
tempUnschedulable
:
'
Temp Unschedulable
'
,
rateLimitedUntil
:
'
Rate limited until {time}
'
,
rateLimitedUntil
:
'
Rate limited until {time}
'
,
overloadedUntil
:
'
Overloaded until {time}
'
,
overloadedUntil
:
'
Overloaded until {time}
'
,
...
@@ -2728,7 +2814,13 @@ export default {
...
@@ -2728,7 +2814,13 @@ export default {
emailVerification
:
'
Email Verification
'
,
emailVerification
:
'
Email Verification
'
,
emailVerificationHint
:
'
Require email verification for new registrations
'
,
emailVerificationHint
:
'
Require email verification for new registrations
'
,
promoCode
:
'
Promo Code
'
,
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
:
{
turnstile
:
{
title
:
'
Cloudflare Turnstile
'
,
title
:
'
Cloudflare Turnstile
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
a161fcc8
...
@@ -143,7 +143,10 @@ export default {
...
@@ -143,7 +143,10 @@ export default {
balance
:
'
余额
'
,
balance
:
'
余额
'
,
available
:
'
可用
'
,
available
:
'
可用
'
,
copiedToClipboard
:
'
已复制到剪贴板
'
,
copiedToClipboard
:
'
已复制到剪贴板
'
,
copied
:
'
已复制
'
,
copyFailed
:
'
复制失败
'
,
copyFailed
:
'
复制失败
'
,
verifying
:
'
验证中...
'
,
processing
:
'
处理中...
'
,
contactSupport
:
'
联系客服
'
,
contactSupport
:
'
联系客服
'
,
add
:
'
添加
'
,
add
:
'
添加
'
,
invalidEmail
:
'
请输入有效的邮箱地址
'
,
invalidEmail
:
'
请输入有效的邮箱地址
'
,
...
@@ -166,7 +169,13 @@ export default {
...
@@ -166,7 +169,13 @@ export default {
justNow
:
'
刚刚
'
,
justNow
:
'
刚刚
'
,
minutesAgo
:
'
{n}分钟前
'
,
minutesAgo
:
'
{n}分钟前
'
,
hoursAgo
:
'
{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 {
...
@@ -262,7 +271,36 @@ export default {
code
:
'
授权码
'
,
code
:
'
授权码
'
,
state
:
'
状态
'
,
state
:
'
状态
'
,
fullUrl
:
'
完整URL
'
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
// Dashboard
...
@@ -544,7 +582,46 @@ export default {
...
@@ -544,7 +582,46 @@ export default {
passwordsNotMatch
:
'
两次输入的密码不一致
'
,
passwordsNotMatch
:
'
两次输入的密码不一致
'
,
passwordTooShort
:
'
密码至少需要 8 个字符
'
,
passwordTooShort
:
'
密码至少需要 8 个字符
'
,
passwordChangeSuccess
:
'
密码修改成功
'
,
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
// Empty States
...
@@ -1096,6 +1173,13 @@ export default {
...
@@ -1096,6 +1173,13 @@ export default {
title
:
'
账号管理
'
,
title
:
'
账号管理
'
,
description
:
'
管理 AI 平台账号和 Cookie
'
,
description
:
'
管理 AI 平台账号和 Cookie
'
,
createAccount
:
'
添加账号
'
,
createAccount
:
'
添加账号
'
,
autoRefresh
:
'
自动刷新
'
,
enableAutoRefresh
:
'
启用自动刷新
'
,
refreshInterval5s
:
'
5 秒
'
,
refreshInterval10s
:
'
10 秒
'
,
refreshInterval15s
:
'
15 秒
'
,
refreshInterval30s
:
'
30 秒
'
,
autoRefreshCountdown
:
'
自动刷新:{seconds}s
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
syncFromCrs
:
'
从 CRS 同步
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsTitle
:
'
从 CRS 同步账号
'
,
syncFromCrsDesc
:
syncFromCrsDesc
:
...
@@ -1205,6 +1289,8 @@ export default {
...
@@ -1205,6 +1289,8 @@ export default {
cooldown
:
'
冷却中
'
,
cooldown
:
'
冷却中
'
,
paused
:
'
暂停
'
,
paused
:
'
暂停
'
,
limited
:
'
限流
'
,
limited
:
'
限流
'
,
rateLimited
:
'
限流中
'
,
overloaded
:
'
过载中
'
,
tempUnschedulable
:
'
临时不可调度
'
,
tempUnschedulable
:
'
临时不可调度
'
,
rateLimitedUntil
:
'
限流中,重置时间:{time}
'
,
rateLimitedUntil
:
'
限流中,重置时间:{time}
'
,
overloadedUntil
:
'
负载过重,重置时间:{time}
'
,
overloadedUntil
:
'
负载过重,重置时间:{time}
'
,
...
@@ -2881,7 +2967,13 @@ export default {
...
@@ -2881,7 +2967,13 @@ export default {
emailVerification
:
'
邮箱验证
'
,
emailVerification
:
'
邮箱验证
'
,
emailVerificationHint
:
'
新用户注册时需要验证邮箱
'
,
emailVerificationHint
:
'
新用户注册时需要验证邮箱
'
,
promoCode
:
'
优惠码
'
,
promoCode
:
'
优惠码
'
,
promoCodeHint
:
'
允许用户在注册时使用优惠码
'
promoCodeHint
:
'
允许用户在注册时使用优惠码
'
,
passwordReset
:
'
忘记密码
'
,
passwordResetHint
:
'
允许用户通过邮箱重置密码
'
,
totp
:
'
双因素认证 (2FA)
'
,
totpHint
:
'
允许用户使用 Google Authenticator 等应用进行二次验证
'
,
totpKeyNotConfigured
:
'
请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。
'
},
},
turnstile
:
{
turnstile
:
{
title
:
'
Cloudflare Turnstile
'
,
title
:
'
Cloudflare Turnstile
'
,
...
...
frontend/src/router/index.ts
View file @
a161fcc8
...
@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
...
@@ -79,6 +79,24 @@ const routes: RouteRecordRaw[] = [
title
:
'
LinuxDo OAuth Callback
'
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 ====================
// ==================== User Routes ====================
{
{
...
...
frontend/src/stores/app.ts
View file @
a161fcc8
...
@@ -313,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
...
@@ -313,6 +313,7 @@ export const useAppStore = defineStore('app', () => {
registration_enabled
:
false
,
registration_enabled
:
false
,
email_verify_enabled
:
false
,
email_verify_enabled
:
false
,
promo_code_enabled
:
true
,
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_enabled
:
false
,
turnstile_site_key
:
''
,
turnstile_site_key
:
''
,
site_name
:
siteName
.
value
,
site_name
:
siteName
.
value
,
...
...
frontend/src/stores/auth.ts
View file @
a161fcc8
...
@@ -5,8 +5,8 @@
...
@@ -5,8 +5,8 @@
import
{
defineStore
}
from
'
pinia
'
import
{
defineStore
}
from
'
pinia
'
import
{
ref
,
computed
,
readonly
}
from
'
vue
'
import
{
ref
,
computed
,
readonly
}
from
'
vue
'
import
{
authAPI
}
from
'
@/api
'
import
{
authAPI
,
isTotp2FARequired
,
type
LoginResponse
}
from
'
@/api
'
import
type
{
User
,
LoginRequest
,
RegisterRequest
}
from
'
@/types
'
import
type
{
User
,
LoginRequest
,
RegisterRequest
,
AuthResponse
}
from
'
@/types
'
const
AUTH_TOKEN_KEY
=
'
auth_token
'
const
AUTH_TOKEN_KEY
=
'
auth_token
'
const
AUTH_USER_KEY
=
'
auth_user
'
const
AUTH_USER_KEY
=
'
auth_user
'
...
@@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => {
...
@@ -91,32 +91,23 @@ export const useAuthStore = defineStore('auth', () => {
/**
/**
* User login
* User login
* @param credentials - Login credentials (
username
and password)
* @param credentials - Login credentials (
email
and password)
* @returns Promise resolving to the
authenticated user
* @returns Promise resolving to the
login response (may require 2FA)
* @throws Error if login fails
* @throws Error if login fails
*/
*/
async
function
login
(
credentials
:
LoginRequest
):
Promise
<
U
se
r
>
{
async
function
login
(
credentials
:
LoginRequest
):
Promise
<
LoginRespon
se
>
{
try
{
try
{
const
response
=
await
authAPI
.
login
(
credentials
)
const
response
=
await
authAPI
.
login
(
credentials
)
// Store token and user
// If 2FA is required, return the response without setting auth state
token
.
value
=
response
.
access_token
if
(
isTotp2FARequired
(
response
))
{
return
response
// 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
// Set auth state from the response
localStorage
.
setItem
(
AUTH_TOKEN_KEY
,
response
.
access_token
)
setAuthFromResponse
(
response
)
localStorage
.
setItem
(
AUTH_USER_KEY
,
JSON
.
stringify
(
userData
))
// Start auto-refresh interval
startAutoRefresh
()
return
userData
return
response
}
catch
(
error
)
{
}
catch
(
error
)
{
// Clear any partial state on error
// Clear any partial state on error
clearAuth
()
clearAuth
()
...
@@ -124,6 +115,47 @@ export const useAuthStore = defineStore('auth', () => {
...
@@ -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
* User registration
* @param userData - Registration data (username, email, password)
* @param userData - Registration data (username, email, password)
...
@@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => {
...
@@ -253,6 +285,7 @@ export const useAuthStore = defineStore('auth', () => {
// Actions
// Actions
login
,
login
,
login2FA
,
register
,
register
,
setToken
,
setToken
,
logout
,
logout
,
...
...
frontend/src/types/index.ts
View file @
a161fcc8
...
@@ -71,6 +71,7 @@ export interface PublicSettings {
...
@@ -71,6 +71,7 @@ export interface PublicSettings {
registration_enabled
:
boolean
registration_enabled
:
boolean
email_verify_enabled
:
boolean
email_verify_enabled
:
boolean
promo_code_enabled
:
boolean
promo_code_enabled
:
boolean
password_reset_enabled
:
boolean
turnstile_enabled
:
boolean
turnstile_enabled
:
boolean
turnstile_site_key
:
string
turnstile_site_key
:
string
site_name
:
string
site_name
:
string
...
@@ -1107,3 +1108,52 @@ export interface UpdatePromoCodeRequest {
...
@@ -1107,3 +1108,52 @@ export interface UpdatePromoCodeRequest {
expires_at
?:
number
|
null
expires_at
?:
number
|
null
notes
?:
string
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
}
frontend/src/utils/format.ts
View file @
a161fcc8
...
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
...
@@ -216,3 +216,48 @@ export function formatTokensK(tokens: number): string {
if
(
tokens
>=
1000
)
return
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
if
(
tokens
>=
1000
)
return
`
${(
tokens
/
1000
).
toFixed
(
1
)}
K`
return
tokens
.
toString
()
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
})
}
frontend/src/views/admin/AccountsView.vue
View file @
a161fcc8
...
@@ -17,10 +17,58 @@
...
@@ -17,10 +17,58 @@
@
create=
"showCreate = true"
@
create=
"showCreate = true"
>
>
<template
#after
>
<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
-->
<!--
Column
Settings
Dropdown
-->
<
div
class
=
"
relative
"
ref
=
"
columnDropdownRef
"
>
<
div
class
=
"
relative
"
ref
=
"
columnDropdownRef
"
>
<
button
<
button
@
click=
"showColumnDropdown = !showColumnDropdown"
@
click
=
"
showColumnDropdown = !showColumnDropdown;
showAutoRefreshDropdown = false
"
class
=
"
btn btn-secondary px-2 md:px-3
"
class
=
"
btn btn-secondary px-2 md:px-3
"
:
title
=
"
t('admin.users.columnSettings')
"
:
title
=
"
t('admin.users.columnSettings')
"
>
>
...
@@ -53,12 +101,29 @@
...
@@ -53,12 +101,29 @@
<
/template
>
<
/template
>
<
template
#
table
>
<
template
#
table
>
<
AccountBulkActionsBar
:
selected
-
ids
=
"
selIds
"
@
delete
=
"
handleBulkDelete
"
@
edit
=
"
showBulkEdit = true
"
@
clear
=
"
selIds = []
"
@
select
-
page
=
"
selectPage
"
@
toggle
-
schedulable
=
"
handleBulkToggleSchedulable
"
/>
<
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
}
"
>
<
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
"
/>
<
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
>
<
template
#cell-name=
"{ value }"
>
<
template
#
cell
-
name
=
"
{ row, value
}
"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
<
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
>
<
template
#
cell
-
notes
=
"
{ value
}
"
>
<
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
>
<
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 @@
...
@@ -161,6 +226,7 @@
<
script
setup
lang
=
"
ts
"
>
<
script
setup
lang
=
"
ts
"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useIntervalFn
}
from
'
@vueuse/core
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAppStore
}
from
'
@/stores/app
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
import
{
useAuthStore
}
from
'
@/stores/auth
'
...
@@ -221,6 +287,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
...
@@ -221,6 +287,26 @@ const hiddenColumns = reactive<Set<string>>(new Set())
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
proxy
'
,
'
notes
'
,
'
priority
'
,
'
rate_multiplier
'
]
const
DEFAULT_HIDDEN_COLUMNS
=
[
'
proxy
'
,
'
notes
'
,
'
priority
'
,
'
rate_multiplier
'
]
const
HIDDEN_COLUMNS_KEY
=
'
account-hidden-columns
'
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
=
()
=>
{
const
loadSavedColumns
=
()
=>
{
try
{
try
{
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
const
saved
=
localStorage
.
getItem
(
HIDDEN_COLUMNS_KEY
)
...
@@ -244,6 +330,60 @@ const saveColumnsToStorage = () => {
...
@@ -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
)
=>
{
const
toggleColumn
=
(
key
:
string
)
=>
{
if
(
hiddenColumns
.
has
(
key
))
{
if
(
hiddenColumns
.
has
(
key
))
{
hiddenColumns
.
delete
(
key
)
hiddenColumns
.
delete
(
key
)
...
@@ -260,6 +400,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
...
@@ -260,6 +400,44 @@ const { items: accounts, loading, params, pagination, load, reload, debouncedRel
initialParams
:
{
platform
:
''
,
type
:
''
,
status
:
''
,
search
:
''
}
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
// All available columns
const
allColumns
=
computed
(()
=>
{
const
allColumns
=
computed
(()
=>
{
const
c
=
[
const
c
=
[
...
@@ -512,10 +690,12 @@ const handleClickOutside = (event: MouseEvent) => {
...
@@ -512,10 +690,12 @@ const handleClickOutside = (event: MouseEvent) => {
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
if
(
columnDropdownRef
.
value
&&
!
columnDropdownRef
.
value
.
contains
(
target
))
{
showColumnDropdown
.
value
=
false
showColumnDropdown
.
value
=
false
}
}
if
(
autoRefreshDropdownRef
.
value
&&
!
autoRefreshDropdownRef
.
value
.
contains
(
target
))
{
showAutoRefreshDropdown
.
value
=
false
}
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
loadSavedColumns
()
load
()
load
()
try
{
try
{
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()])
const
[
p
,
g
]
=
await
Promise
.
all
([
adminAPI
.
proxies
.
getAll
(),
adminAPI
.
groups
.
getAll
()])
...
@@ -526,6 +706,13 @@ onMounted(async () => {
...
@@ -526,6 +706,13 @@ onMounted(async () => {
}
}
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
window
.
addEventListener
(
'
scroll
'
,
handleScroll
,
true
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
document
.
addEventListener
(
'
click
'
,
handleClickOutside
)
if
(
autoRefreshEnabled
.
value
)
{
autoRefreshCountdown
.
value
=
autoRefreshIntervalSeconds
.
value
resumeAutoRefresh
()
}
else
{
pauseAutoRefresh
()
}
}
)
}
)
onUnmounted
(()
=>
{
onUnmounted
(()
=>
{
...
...
frontend/src/views/admin/SettingsView.vue
View file @
a161fcc8
...
@@ -338,6 +338,47 @@
...
@@ -338,6 +338,47 @@
</div>
</div>
<Toggle
v-model=
"form.promo_code_enabled"
/>
<Toggle
v-model=
"form.promo_code_enabled"
/>
</div>
</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>
</div>
</div>
...
@@ -1029,6 +1070,9 @@ const form = reactive<SettingsForm>({
...
@@ -1029,6 +1070,9 @@ const form = reactive<SettingsForm>({
registration_enabled
:
true
,
registration_enabled
:
true
,
email_verify_enabled
:
false
,
email_verify_enabled
:
false
,
promo_code_enabled
:
true
,
promo_code_enabled
:
true
,
password_reset_enabled
:
false
,
totp_enabled
:
false
,
totp_encryption_key_configured
:
false
,
default_balance
:
0
,
default_balance
:
0
,
default_concurrency
:
1
,
default_concurrency
:
1
,
site_name
:
'
Sub2API
'
,
site_name
:
'
Sub2API
'
,
...
@@ -1152,6 +1196,8 @@ async function saveSettings() {
...
@@ -1152,6 +1196,8 @@ async function saveSettings() {
registration_enabled
:
form
.
registration_enabled
,
registration_enabled
:
form
.
registration_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
email_verify_enabled
:
form
.
email_verify_enabled
,
promo_code_enabled
:
form
.
promo_code_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_balance
:
form
.
default_balance
,
default_concurrency
:
form
.
default_concurrency
,
default_concurrency
:
form
.
default_concurrency
,
site_name
:
form
.
site_name
,
site_name
:
form
.
site_name
,
...
...
frontend/src/views/admin/SubscriptionsView.vue
View file @
a161fcc8
...
@@ -154,7 +154,13 @@
...
@@ -154,7 +154,13 @@
<!-- Subscriptions Table -->
<!-- Subscriptions Table -->
<
template
#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 }">
<template
#cell-user
="
{ row }">
<div
class=
"flex items-center gap-2"
>
<div
class=
"flex items-center gap-2"
>
<div
<div
...
@@ -357,7 +363,7 @@
...
@@ -357,7 +363,7 @@
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
button
<
button
v
-
if
=
"
row.status === 'active'
"
v
-
if
=
"
row.status === 'active'
|| row.status === 'expired'
"
@
click
=
"
handleExtend(row)
"
@
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
"
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[]>(() => [
...
@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
label
:
userColumnMode
.
value
===
'
email
'
label
:
userColumnMode
.
value
===
'
email
'
?
t
(
'
admin.subscriptions.columns.user
'
)
?
t
(
'
admin.subscriptions.columns.user
'
)
:
t
(
'
admin.users.columns.username
'
),
:
t
(
'
admin.users.columns.username
'
),
sortable
:
tru
e
sortable
:
fals
e
}
,
}
,
{
key
:
'
group
'
,
label
:
t
(
'
admin.subscriptions.columns.group
'
),
sortable
:
tru
e
}
,
{
key
:
'
group
'
,
label
:
t
(
'
admin.subscriptions.columns.group
'
),
sortable
:
fals
e
}
,
{
key
:
'
usage
'
,
label
:
t
(
'
admin.subscriptions.columns.usage
'
),
sortable
:
false
}
,
{
key
:
'
usage
'
,
label
:
t
(
'
admin.subscriptions.columns.usage
'
),
sortable
:
false
}
,
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.subscriptions.columns.expires
'
),
sortable
:
true
}
,
{
key
:
'
expires_at
'
,
label
:
t
(
'
admin.subscriptions.columns.expires
'
),
sortable
:
true
}
,
{
key
:
'
status
'
,
label
:
t
(
'
admin.subscriptions.columns.status
'
),
sortable
:
true
}
,
{
key
:
'
status
'
,
label
:
t
(
'
admin.subscriptions.columns.status
'
),
sortable
:
true
}
,
...
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
...
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
filters
=
reactive
({
const
filters
=
reactive
({
status
:
''
,
status
:
'
active
'
,
group_id
:
''
,
group_id
:
''
,
user_id
:
null
as
number
|
null
user_id
:
null
as
number
|
null
}
)
}
)
// Sorting state
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
)
const
pagination
=
reactive
({
const
pagination
=
reactive
({
page
:
1
,
page
:
1
,
page_size
:
20
,
page_size
:
20
,
...
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
...
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
{
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
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
signal
...
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
...
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadSubscriptions
()
loadSubscriptions
()
}
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadSubscriptions
()
}
const
closeAssignModal
=
()
=>
{
const
closeAssignModal
=
()
=>
{
showAssignModal
.
value
=
false
showAssignModal
.
value
=
false
assignForm
.
user_id
=
null
assignForm
.
user_id
=
null
...
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
...
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
const
handleExtendSubscription
=
async
()
=>
{
const
handleExtendSubscription
=
async
()
=>
{
if
(
!
extendingSubscription
.
value
)
return
if
(
!
extendingSubscription
.
value
)
return
// 前端验证:调整后
剩余天数必须 > 0
// 前端验证:调整后
的过期时间必须在未来
if
(
extendingSubscription
.
value
.
expires_at
)
{
if
(
extendingSubscription
.
value
.
expires_at
)
{
const
currentDaysRemaining
=
getDaysRemaining
(
extendingSubscription
.
value
.
expires_at
)
??
0
const
expiresAt
=
new
Date
(
extendingSubscription
.
value
.
expires_at
)
const
new
DaysRemaining
=
currentDaysRemaining
+
extendForm
.
days
const
new
ExpiresAt
=
new
Date
(
expiresAt
.
getTime
()
+
extendForm
.
days
*
24
*
60
*
60
*
1000
)
if
(
new
DaysRemaining
<=
0
)
{
if
(
new
ExpiresAt
<=
new
Date
()
)
{
appStore
.
showError
(
t
(
'
admin.subscriptions.adjustWouldExpire
'
))
appStore
.
showError
(
t
(
'
admin.subscriptions.adjustWouldExpire
'
))
return
return
}
}
...
...
frontend/src/views/auth/ForgotPasswordView.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/views/auth/LoginView.vue
View file @
a161fcc8
...
@@ -72,9 +72,19 @@
...
@@ -72,9 +72,19 @@
<Icon
v-else
name=
"eye"
size=
"md"
/>
<Icon
v-else
name=
"eye"
size=
"md"
/>
</button>
</button>
</div>
</div>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
<div
class=
"mt-1 flex items-center justify-between"
>
{{
errors
.
password
}}
<p
v-if=
"errors.password"
class=
"input-error-text"
>
</p>
{{
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>
</div>
<!-- Turnstile Widget -->
<!-- Turnstile Widget -->
...
@@ -153,6 +163,16 @@
...
@@ -153,6 +163,16 @@
</p>
</p>
</
template
>
</
template
>
</AuthLayout>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if=
"show2FAModal"
ref=
"totpModalRef"
:temp-token=
"totpTempToken"
:user-email-masked=
"totpUserEmailMasked"
@
verify=
"handle2FAVerify"
@
cancel=
"handle2FACancel"
/>
</template>
</template>
<
script
setup
lang=
"ts"
>
<
script
setup
lang=
"ts"
>
...
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
...
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
isTotp2FARequired
}
from
'
@/api/auth
'
import
type
{
TotpLoginResponse
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
...
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
...
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
// Turnstile
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
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
({
const
formData
=
reactive
({
email
:
''
,
email
:
''
,
password
:
''
password
:
''
...
@@ -216,6 +245,7 @@ onMounted(async () => {
...
@@ -216,6 +245,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
}
...
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
...
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
try
{
try
{
// Call auth store login
// Call auth store login
await
authStore
.
login
({
const
response
=
await
authStore
.
login
({
email
:
formData
.
email
,
email
:
formData
.
email
,
password
:
formData
.
password
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
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
// Show success toast
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
...
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
...
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
isLoading
.
value
=
false
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
>
</
script
>
<
style
scoped
>
<
style
scoped
>
...
...
frontend/src/views/auth/ResetPasswordView.vue
0 → 100644
View file @
a161fcc8
<
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
>
frontend/src/views/user/ProfileView.vue
View file @
a161fcc8
...
@@ -15,6 +15,7 @@
...
@@ -15,6 +15,7 @@
</div>
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfilePasswordForm
/>
<ProfilePasswordForm
/>
<ProfileTotpCard
/>
</div>
</div>
</AppLayout>
</AppLayout>
</
template
>
</
template
>
...
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
...
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
{
Icon
}
from
'
@/components/icons
'
import
{
Icon
}
from
'
@/components/icons
'
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment