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
377bffe2
Commit
377bffe2
authored
Feb 03, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test
parents
99250ec5
31fe0178
Changes
235
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/admin/SubscriptionsView.vue
View file @
377bffe2
...
...
@@ -154,7 +154,13 @@
<!-- Subscriptions Table -->
<
template
#table
>
<DataTable
:columns=
"columns"
:data=
"subscriptions"
:loading=
"loading"
>
<DataTable
:columns=
"columns"
:data=
"subscriptions"
:loading=
"loading"
:server-side-sort=
"true"
@
sort=
"handleSort"
>
<template
#cell-user
="
{ row }">
<div
class=
"flex items-center gap-2"
>
<div
...
...
@@ -357,7 +363,7 @@
<
template
#
cell
-
actions
=
"
{ row
}
"
>
<
div
class
=
"
flex items-center gap-1
"
>
<
button
v
-
if
=
"
row.status === 'active'
"
v
-
if
=
"
row.status === 'active'
|| row.status === 'expired'
"
@
click
=
"
handleExtend(row)
"
class
=
"
flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400
"
>
...
...
@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
label
:
userColumnMode
.
value
===
'
email
'
?
t
(
'
admin.subscriptions.columns.user
'
)
:
t
(
'
admin.users.columns.username
'
),
sortable
:
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
:
'
expires_at
'
,
label
:
t
(
'
admin.subscriptions.columns.expires
'
),
sortable
:
true
}
,
{
key
:
'
status
'
,
label
:
t
(
'
admin.subscriptions.columns.status
'
),
sortable
:
true
}
,
...
...
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
let
userSearchTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
filters
=
reactive
({
status
:
''
,
status
:
'
active
'
,
group_id
:
''
,
user_id
:
null
as
number
|
null
}
)
// Sorting state
const
sortState
=
reactive
({
sort_by
:
'
created_at
'
,
sort_order
:
'
desc
'
as
'
asc
'
|
'
desc
'
}
)
const
pagination
=
reactive
({
page
:
1
,
page_size
:
20
,
...
...
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
{
status
:
(
filters
.
status
as
any
)
||
undefined
,
group_id
:
filters
.
group_id
?
parseInt
(
filters
.
group_id
)
:
undefined
,
user_id
:
filters
.
user_id
||
undefined
user_id
:
filters
.
user_id
||
undefined
,
sort_by
:
sortState
.
sort_by
,
sort_order
:
sortState
.
sort_order
}
,
{
signal
...
...
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
loadSubscriptions
()
}
const
handleSort
=
(
key
:
string
,
order
:
'
asc
'
|
'
desc
'
)
=>
{
sortState
.
sort_by
=
key
sortState
.
sort_order
=
order
pagination
.
page
=
1
loadSubscriptions
()
}
const
closeAssignModal
=
()
=>
{
showAssignModal
.
value
=
false
assignForm
.
user_id
=
null
...
...
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
const
handleExtendSubscription
=
async
()
=>
{
if
(
!
extendingSubscription
.
value
)
return
// 前端验证:调整后
剩余天数必须 > 0
// 前端验证:调整后
的过期时间必须在未来
if
(
extendingSubscription
.
value
.
expires_at
)
{
const
currentDaysRemaining
=
getDaysRemaining
(
extendingSubscription
.
value
.
expires_at
)
??
0
const
new
DaysRemaining
=
currentDaysRemaining
+
extendForm
.
days
if
(
new
DaysRemaining
<=
0
)
{
const
expiresAt
=
new
Date
(
extendingSubscription
.
value
.
expires_at
)
const
new
ExpiresAt
=
new
Date
(
expiresAt
.
getTime
()
+
extendForm
.
days
*
24
*
60
*
60
*
1000
)
if
(
new
ExpiresAt
<=
new
Date
()
)
{
appStore
.
showError
(
t
(
'
admin.subscriptions.adjustWouldExpire
'
))
return
}
...
...
frontend/src/views/admin/UsageView.vue
View file @
377bffe2
...
...
@@ -35,12 +35,13 @@
<
script
setup
lang=
"ts"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onUnmounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
;
import
Select
from
'
@/components/common/Select.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
{
saveAs
}
from
'
file-saver
'
import
{
useAppStore
}
from
'
@/stores/app
'
;
import
{
adminAPI
}
from
'
@/api/admin
'
;
import
{
adminUsageAPI
}
from
'
@/api/admin/usage
'
import
{
formatReasoningEffort
}
from
'
@/utils/format
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
;
import
Pagination
from
'
@/components/common/Pagination.vue
'
;
import
Select
from
'
@/components/common/Select.vue
'
import
UsageStatsCards
from
'
@/components/admin/usage/UsageStatsCards.vue
'
;
import
UsageFilters
from
'
@/components/admin/usage/UsageFilters.vue
'
import
UsageTable
from
'
@/components/admin/usage/UsageTable.vue
'
;
import
UsageExportProgress
from
'
@/components/admin/usage/UsageExportProgress.vue
'
import
UsageCleanupDialog
from
'
@/components/admin/usage/UsageCleanupDialog.vue
'
import
ModelDistributionChart
from
'
@/components/charts/ModelDistributionChart.vue
'
;
import
TokenUsageTrend
from
'
@/components/charts/TokenUsageTrend.vue
'
import
type
{
AdminUsageLog
,
TrendDataPoint
,
ModelStat
}
from
'
@/types
'
;
import
type
{
AdminUsageStatsResponse
,
AdminUsageQueryParams
}
from
'
@/api/admin/usage
'
...
...
@@ -104,7 +105,7 @@ const exportToExcel = async () => {
const
XLSX
=
await
import
(
'
xlsx
'
)
const
headers
=
[
t
(
'
usage.time
'
),
t
(
'
admin.usage.user
'
),
t
(
'
usage.apiKeyFilter
'
),
t
(
'
admin.usage.account
'
),
t
(
'
usage.model
'
),
t
(
'
admin.usage.group
'
),
t
(
'
admin.usage.account
'
),
t
(
'
usage.model
'
),
t
(
'
usage.reasoningEffort
'
),
t
(
'
admin.usage.group
'
),
t
(
'
usage.type
'
),
t
(
'
admin.usage.inputTokens
'
),
t
(
'
admin.usage.outputTokens
'
),
t
(
'
admin.usage.cacheReadTokens
'
),
t
(
'
admin.usage.cacheCreationTokens
'
),
...
...
@@ -120,6 +121,7 @@ const exportToExcel = async () => {
log
.
api_key
?.
name
||
''
,
log
.
account
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
group
?.
name
||
''
,
log
.
stream
?
t
(
'
usage.stream
'
)
:
t
(
'
usage.sync
'
),
log
.
input_tokens
,
...
...
frontend/src/views/admin/UsersView.vue
View file @
377bffe2
...
...
@@ -300,8 +300,29 @@
</span>
</
template
>
<
template
#cell-balance=
"{ value }"
>
<span
class=
"font-medium text-gray-900 dark:text-white"
>
$
{{
value
.
toFixed
(
2
)
}}
</span>
<
template
#cell-balance=
"{ value, row }"
>
<div
class=
"flex items-center gap-2"
>
<div
class=
"group relative"
>
<button
class=
"font-medium text-gray-900 underline decoration-dashed decoration-gray-300 underline-offset-4 transition-colors hover:text-primary-600 dark:text-white dark:decoration-dark-500 dark:hover:text-primary-400"
@
click=
"handleBalanceHistory(row)"
>
$
{{
value
.
toFixed
(
2
)
}}
</button>
<!-- Instant tooltip -->
<div
class=
"pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity duration-75 group-hover:opacity-100 dark:bg-dark-600"
>
{{
t
(
'
admin.users.balanceHistoryTip
'
)
}}
<div
class=
"absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-dark-600"
></div>
</div>
</div>
<button
@
click.stop=
"handleDeposit(row)"
class=
"rounded px-2 py-0.5 text-xs font-medium text-emerald-600 transition-colors hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-900/20"
:title=
"t('admin.users.deposit')"
>
{{
t
(
'
admin.users.deposit
'
)
}}
</button>
</div>
</
template
>
<
template
#cell-usage=
"{ row }"
>
...
...
@@ -456,6 +477,15 @@
{{
t
(
'
admin.users.withdraw
'
)
}}
</button>
<!-- Balance History -->
<button
@
click=
"handleBalanceHistory(user); closeActionMenu()"
class=
"flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
<Icon
name=
"dollar"
size=
"sm"
class=
"text-gray-400"
:stroke-width=
"2"
/>
{{
t
(
'
admin.users.balanceHistory
'
)
}}
</button>
<div
class=
"my-1 border-t border-gray-100 dark:border-dark-700"
></div>
<!-- Delete (not for admin) -->
...
...
@@ -479,6 +509,7 @@
<UserApiKeysModal
:show=
"showApiKeysModal"
:user=
"viewingUser"
@
close=
"closeApiKeysModal"
/>
<UserAllowedGroupsModal
:show=
"showAllowedGroupsModal"
:user=
"allowedGroupsUser"
@
close=
"closeAllowedGroupsModal"
@
success=
"loadUsers"
/>
<UserBalanceModal
:show=
"showBalanceModal"
:user=
"balanceUser"
:operation=
"balanceOperation"
@
close=
"closeBalanceModal"
@
success=
"loadUsers"
/>
<UserBalanceHistoryModal
:show=
"showBalanceHistoryModal"
:user=
"balanceHistoryUser"
@
close=
"closeBalanceHistoryModal"
@
deposit=
"handleDepositFromHistory"
@
withdraw=
"handleWithdrawFromHistory"
/>
<UserAttributesConfigModal
:show=
"showAttributesModal"
@
close=
"handleAttributesModalClose"
/>
</AppLayout>
</template>
...
...
@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import
UserApiKeysModal
from
'
@/components/admin/user/UserApiKeysModal.vue
'
import
UserAllowedGroupsModal
from
'
@/components/admin/user/UserAllowedGroupsModal.vue
'
import
UserBalanceModal
from
'
@/components/admin/user/UserBalanceModal.vue
'
import
UserBalanceHistoryModal
from
'
@/components/admin/user/UserBalanceHistoryModal.vue
'
const
appStore
=
useAppStore
()
...
...
@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
const
balanceUser
=
ref
<
AdminUser
|
null
>
(
null
)
const
balanceOperation
=
ref
<
'
add
'
|
'
subtract
'
>
(
'
add
'
)
// Balance History modal state
const
showBalanceHistoryModal
=
ref
(
false
)
const
balanceHistoryUser
=
ref
<
AdminUser
|
null
>
(
null
)
// 计算剩余天数
const
getDaysRemaining
=
(
expiresAt
:
string
):
number
=>
{
const
now
=
new
Date
()
...
...
@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
balanceUser
.
value
=
null
}
const
handleBalanceHistory
=
(
user
:
AdminUser
)
=>
{
balanceHistoryUser
.
value
=
user
showBalanceHistoryModal
.
value
=
true
}
const
closeBalanceHistoryModal
=
()
=>
{
showBalanceHistoryModal
.
value
=
false
balanceHistoryUser
.
value
=
null
}
// Handle deposit from balance history modal
const
handleDepositFromHistory
=
()
=>
{
if
(
balanceHistoryUser
.
value
)
{
handleDeposit
(
balanceHistoryUser
.
value
)
}
}
// Handle withdraw from balance history modal
const
handleWithdrawFromHistory
=
()
=>
{
if
(
balanceHistoryUser
.
value
)
{
handleWithdraw
(
balanceHistoryUser
.
value
)
}
}
// 滚动时关闭菜单
const
handleScroll
=
()
=>
{
closeActionMenu
()
...
...
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
View file @
377bffe2
...
...
@@ -49,6 +49,7 @@ interface SummaryRow {
total_accounts
:
number
available_accounts
:
number
rate_limited_accounts
:
number
scope_rate_limit_count
?:
Record
<
string
,
number
>
error_accounts
:
number
// 并发统计
total_concurrency
:
number
...
...
@@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => {
total_accounts
:
totalAccounts
,
available_accounts
:
availableAccounts
,
rate_limited_accounts
:
safeNumber
(
avail
.
rate_limit_count
),
scope_rate_limit_count
:
avail
.
scope_rate_limit_count
,
error_accounts
:
safeNumber
(
avail
.
error_count
),
total_concurrency
:
totalConcurrency
,
used_concurrency
:
usedConcurrency
,
...
...
@@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => {
total_accounts
:
totalAccounts
,
available_accounts
:
availableAccounts
,
rate_limited_accounts
:
safeNumber
(
avail
.
rate_limit_count
),
scope_rate_limit_count
:
avail
.
scope_rate_limit_count
,
error_accounts
:
safeNumber
(
avail
.
error_count
),
total_concurrency
:
totalConcurrency
,
used_concurrency
:
usedConcurrency
,
...
...
@@ -269,6 +272,15 @@ function formatDuration(seconds: number): string {
return
`
${
hours
}
h`
}
function
formatScopeName
(
scope
:
string
):
string
{
const
names
:
Record
<
string
,
string
>
=
{
claude
:
'
Claude
'
,
gemini_text
:
'
Gemini
'
,
gemini_image
:
'
Image
'
}
return
names
[
scope
]
||
scope
}
watch
(
()
=>
realtimeEnabled
.
value
,
async
(
enabled
)
=>
{
...
...
@@ -387,6 +399,18 @@ watch(
{{
t
(
'
admin.ops.concurrency.rateLimited
'
,
{
count
:
row
.
rate_limited_accounts
}
)
}}
<
/span
>
<!--
Scope
限流
(
仅
Antigravity
)
-->
<
template
v
-
if
=
"
row.scope_rate_limit_count && Object.keys(row.scope_rate_limit_count).length > 0
"
>
<
span
v
-
for
=
"
(count, scope) in row.scope_rate_limit_count
"
:
key
=
"
scope
"
class
=
"
rounded-full bg-orange-100 px-1.5 py-0.5 font-semibold text-orange-700 dark:bg-orange-900/30 dark:text-orange-400
"
:
title
=
"
t('admin.ops.concurrency.scopeRateLimitedTooltip', { scope, count
}
)
"
>
{{
formatScopeName
(
scope
as
string
)
}}
{{
count
}}
<
/span
>
<
/template
>
<!--
异常账号
-->
<
span
v
-
if
=
"
row.error_accounts > 0
"
...
...
frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
View file @
377bffe2
...
...
@@ -505,6 +505,16 @@ async function saveAllSettings() {
</div>
<Toggle
v-model=
"advancedSettings.ignore_no_available_accounts"
/>
</div>
<div
class=
"flex items-center justify-between"
>
<div>
<label
class=
"text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t
(
'
admin.ops.settings.ignoreInvalidApiKeyErrors
'
)
}}
</label>
<p
class=
"mt-1 text-xs text-gray-500"
>
{{
t
(
'
admin.ops.settings.ignoreInvalidApiKeyErrorsHint
'
)
}}
</p>
</div>
<Toggle
v-model=
"advancedSettings.ignore_invalid_api_key_errors"
/>
</div>
</div>
<!-- Auto Refresh -->
...
...
frontend/src/views/auth/EmailVerifyView.vue
View file @
377bffe2
...
...
@@ -201,6 +201,7 @@ const email = ref<string>('')
const
password
=
ref
<
string
>
(
''
)
const
initialTurnstileToken
=
ref
<
string
>
(
''
)
const
promoCode
=
ref
<
string
>
(
''
)
const
invitationCode
=
ref
<
string
>
(
''
)
const
hasRegisterData
=
ref
<
boolean
>
(
false
)
// Public settings
...
...
@@ -230,6 +231,7 @@ onMounted(async () => {
password
.
value
=
registerData
.
password
||
''
initialTurnstileToken
.
value
=
registerData
.
turnstile_token
||
''
promoCode
.
value
=
registerData
.
promo_code
||
''
invitationCode
.
value
=
registerData
.
invitation_code
||
''
hasRegisterData
.
value
=
!!
(
email
.
value
&&
password
.
value
)
}
catch
{
hasRegisterData
.
value
=
false
...
...
@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
password
:
password
.
value
,
verify_code
:
verifyCode
.
value
.
trim
(),
turnstile_token
:
initialTurnstileToken
.
value
||
undefined
,
promo_code
:
promoCode
.
value
||
undefined
promo_code
:
promoCode
.
value
||
undefined
,
invitation_code
:
invitationCode
.
value
||
undefined
})
// Clear session data
...
...
frontend/src/views/auth/ForgotPasswordView.vue
0 → 100644
View file @
377bffe2
<
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 @
377bffe2
...
...
@@ -72,9 +72,19 @@
<Icon
v-else
name=
"eye"
size=
"md"
/>
</button>
</div>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
{{
errors
.
password
}}
</p>
<div
class=
"mt-1 flex items-center justify-between"
>
<p
v-if=
"errors.password"
class=
"input-error-text"
>
{{
errors
.
password
}}
</p>
<span
v-else
></span>
<router-link
v-if=
"passwordResetEnabled"
to=
"/forgot-password"
class=
"text-sm font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{
t
(
'
auth.forgotPassword
'
)
}}
</router-link>
</div>
</div>
<!-- Turnstile Widget -->
...
...
@@ -153,6 +163,16 @@
</p>
</
template
>
</AuthLayout>
<!-- 2FA Modal -->
<TotpLoginModal
v-if=
"show2FAModal"
ref=
"totpModalRef"
:temp-token=
"totpTempToken"
:user-email-masked=
"totpUserEmailMasked"
@
verify=
"handle2FAVerify"
@
cancel=
"handle2FACancel"
/>
</template>
<
script
setup
lang=
"ts"
>
...
...
@@ -161,10 +181,12 @@ import { useRouter } from 'vue-router'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
AuthLayout
}
from
'
@/components/layout
'
import
LinuxDoOAuthSection
from
'
@/components/auth/LinuxDoOAuthSection.vue
'
import
TotpLoginModal
from
'
@/components/auth/TotpLoginModal.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
isTotp2FARequired
}
from
'
@/api/auth
'
import
type
{
TotpLoginResponse
}
from
'
@/types
'
const
{
t
}
=
useI18n
()
...
...
@@ -184,11 +206,18 @@ const showPassword = ref<boolean>(false)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
linuxdoOAuthEnabled
=
ref
<
boolean
>
(
false
)
const
passwordResetEnabled
=
ref
<
boolean
>
(
false
)
// Turnstile
const
turnstileRef
=
ref
<
InstanceType
<
typeof
TurnstileWidget
>
|
null
>
(
null
)
const
turnstileToken
=
ref
<
string
>
(
''
)
// 2FA state
const
show2FAModal
=
ref
<
boolean
>
(
false
)
const
totpTempToken
=
ref
<
string
>
(
''
)
const
totpUserEmailMasked
=
ref
<
string
>
(
''
)
const
totpModalRef
=
ref
<
InstanceType
<
typeof
TotpLoginModal
>
|
null
>
(
null
)
const
formData
=
reactive
({
email
:
''
,
password
:
''
...
...
@@ -216,6 +245,7 @@ onMounted(async () => {
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
linuxdoOAuthEnabled
.
value
=
settings
.
linuxdo_oauth_enabled
passwordResetEnabled
.
value
=
settings
.
password_reset_enabled
}
catch
(
error
)
{
console
.
error
(
'
Failed to load public settings:
'
,
error
)
}
...
...
@@ -290,12 +320,22 @@ async function handleLogin(): Promise<void> {
try
{
// Call auth store login
await
authStore
.
login
({
const
response
=
await
authStore
.
login
({
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
})
// Check if 2FA is required
if
(
isTotp2FARequired
(
response
))
{
const
totpResponse
=
response
as
TotpLoginResponse
totpTempToken
.
value
=
totpResponse
.
temp_token
||
''
totpUserEmailMasked
.
value
=
totpResponse
.
user_email_masked
||
''
show2FAModal
.
value
=
true
isLoading
.
value
=
false
return
}
// Show success toast
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
...
...
@@ -326,6 +366,40 @@ async function handleLogin(): Promise<void> {
isLoading
.
value
=
false
}
}
// ==================== 2FA Handlers ====================
async
function
handle2FAVerify
(
code
:
string
):
Promise
<
void
>
{
if
(
totpModalRef
.
value
)
{
totpModalRef
.
value
.
setVerifying
(
true
)
}
try
{
await
authStore
.
login2FA
(
totpTempToken
.
value
,
code
)
// Close modal and show success
show2FAModal
.
value
=
false
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
// Redirect to dashboard or intended route
const
redirectTo
=
(
router
.
currentRoute
.
value
.
query
.
redirect
as
string
)
||
'
/dashboard
'
await
router
.
push
(
redirectTo
)
}
catch
(
error
:
unknown
)
{
const
err
=
error
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
const
message
=
err
.
response
?.
data
?.
message
||
err
.
message
||
t
(
'
profile.totp.loginFailed
'
)
if
(
totpModalRef
.
value
)
{
totpModalRef
.
value
.
setError
(
message
)
totpModalRef
.
value
.
setVerifying
(
false
)
}
}
}
function
handle2FACancel
():
void
{
show2FAModal
.
value
=
false
totpTempToken
.
value
=
''
totpUserEmailMasked
.
value
=
''
}
</
script
>
<
style
scoped
>
...
...
frontend/src/views/auth/RegisterView.vue
View file @
377bffe2
...
...
@@ -95,6 +95,59 @@
<
/p
>
<
/div
>
<!--
Invitation
Code
Input
(
Required
when
enabled
)
-->
<
div
v
-
if
=
"
invitationCodeEnabled
"
>
<
label
for
=
"
invitation_code
"
class
=
"
input-label
"
>
{{
t
(
'
auth.invitationCodeLabel
'
)
}}
<
/label
>
<
div
class
=
"
relative
"
>
<
div
class
=
"
pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5
"
>
<
Icon
name
=
"
key
"
size
=
"
md
"
:
class
=
"
invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'
"
/>
<
/div
>
<
input
id
=
"
invitation_code
"
v
-
model
=
"
formData.invitation_code
"
type
=
"
text
"
:
disabled
=
"
isLoading
"
class
=
"
input pl-11 pr-10
"
:
class
=
"
{
'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid,
'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code
}
"
:
placeholder
=
"
t('auth.invitationCodePlaceholder')
"
@
input
=
"
handleInvitationCodeInput
"
/>
<!--
Validation
indicator
-->
<
div
v
-
if
=
"
invitationValidating
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
svg
class
=
"
h-4 w-4 animate-spin text-gray-400
"
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
>
<
/div
>
<
div
v
-
else
-
if
=
"
invitationValidation.valid
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
checkCircle
"
size
=
"
md
"
class
=
"
text-green-500
"
/>
<
/div
>
<
div
v
-
else
-
if
=
"
invitationValidation.invalid || errors.invitation_code
"
class
=
"
absolute inset-y-0 right-0 flex items-center pr-3.5
"
>
<
Icon
name
=
"
exclamationCircle
"
size
=
"
md
"
class
=
"
text-red-500
"
/>
<
/div
>
<
/div
>
<!--
Invitation
code
validation
result
-->
<
transition
name
=
"
fade
"
>
<
div
v
-
if
=
"
invitationValidation.valid
"
class
=
"
mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20
"
>
<
Icon
name
=
"
checkCircle
"
size
=
"
sm
"
class
=
"
text-green-600 dark:text-green-400
"
/>
<
span
class
=
"
text-sm text-green-700 dark:text-green-400
"
>
{{
t
(
'
auth.invitationCodeValid
'
)
}}
<
/span
>
<
/div
>
<
p
v
-
else
-
if
=
"
invitationValidation.invalid
"
class
=
"
input-error-text
"
>
{{
invitationValidation
.
message
}}
<
/p
>
<
p
v
-
else
-
if
=
"
errors.invitation_code
"
class
=
"
input-error-text
"
>
{{
errors
.
invitation_code
}}
<
/p
>
<
/transition
>
<
/div
>
<!--
Promo
Code
Input
(
Optional
)
-->
<
div
v
-
if
=
"
promoCodeEnabled
"
>
<
label
for
=
"
promo_code
"
class
=
"
input-label
"
>
...
...
@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
TurnstileWidget
from
'
@/components/TurnstileWidget.vue
'
import
{
useAuthStore
,
useAppStore
}
from
'
@/stores
'
import
{
getPublicSettings
,
validatePromoCode
}
from
'
@/api/auth
'
import
{
getPublicSettings
,
validatePromoCode
,
validateInvitationCode
}
from
'
@/api/auth
'
const
{
t
}
=
useI18n
()
...
...
@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
const
registrationEnabled
=
ref
<
boolean
>
(
true
)
const
emailVerifyEnabled
=
ref
<
boolean
>
(
false
)
const
promoCodeEnabled
=
ref
<
boolean
>
(
true
)
const
invitationCodeEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileEnabled
=
ref
<
boolean
>
(
false
)
const
turnstileSiteKey
=
ref
<
string
>
(
''
)
const
siteName
=
ref
<
string
>
(
'
Sub2API
'
)
...
...
@@ -280,16 +334,27 @@ const promoValidation = reactive({
}
)
let
promoValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
// Invitation code validation
const
invitationValidating
=
ref
<
boolean
>
(
false
)
const
invitationValidation
=
reactive
({
valid
:
false
,
invalid
:
false
,
message
:
''
}
)
let
invitationValidateTimeout
:
ReturnType
<
typeof
setTimeout
>
|
null
=
null
const
formData
=
reactive
({
email
:
''
,
password
:
''
,
promo_code
:
''
promo_code
:
''
,
invitation_code
:
''
}
)
const
errors
=
reactive
({
email
:
''
,
password
:
''
,
turnstile
:
''
turnstile
:
''
,
invitation_code
:
''
}
)
// ==================== Lifecycle ====================
...
...
@@ -300,6 +365,7 @@ onMounted(async () => {
registrationEnabled
.
value
=
settings
.
registration_enabled
emailVerifyEnabled
.
value
=
settings
.
email_verify_enabled
promoCodeEnabled
.
value
=
settings
.
promo_code_enabled
invitationCodeEnabled
.
value
=
settings
.
invitation_code_enabled
turnstileEnabled
.
value
=
settings
.
turnstile_enabled
turnstileSiteKey
.
value
=
settings
.
turnstile_site_key
||
''
siteName
.
value
=
settings
.
site_name
||
'
Sub2API
'
...
...
@@ -325,6 +391,9 @@ onUnmounted(() => {
if
(
promoValidateTimeout
)
{
clearTimeout
(
promoValidateTimeout
)
}
if
(
invitationValidateTimeout
)
{
clearTimeout
(
invitationValidateTimeout
)
}
}
)
// ==================== Promo Code Validation ====================
...
...
@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string {
}
}
// ==================== Invitation Code Validation ====================
function
handleInvitationCodeInput
():
void
{
const
code
=
formData
.
invitation_code
.
trim
()
// Clear previous validation
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
false
invitationValidation
.
message
=
''
errors
.
invitation_code
=
''
if
(
!
code
)
{
return
}
// Debounce validation
if
(
invitationValidateTimeout
)
{
clearTimeout
(
invitationValidateTimeout
)
}
invitationValidateTimeout
=
setTimeout
(()
=>
{
validateInvitationCodeDebounced
(
code
)
}
,
500
)
}
async
function
validateInvitationCodeDebounced
(
code
:
string
):
Promise
<
void
>
{
invitationValidating
.
value
=
true
try
{
const
result
=
await
validateInvitationCode
(
code
)
if
(
result
.
valid
)
{
invitationValidation
.
valid
=
true
invitationValidation
.
invalid
=
false
invitationValidation
.
message
=
''
}
else
{
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
true
invitationValidation
.
message
=
getInvitationErrorMessage
(
result
.
error_code
)
}
}
catch
{
invitationValidation
.
valid
=
false
invitationValidation
.
invalid
=
true
invitationValidation
.
message
=
t
(
'
auth.invitationCodeInvalid
'
)
}
finally
{
invitationValidating
.
value
=
false
}
}
function
getInvitationErrorMessage
(
errorCode
?:
string
):
string
{
switch
(
errorCode
)
{
case
'
INVITATION_CODE_NOT_FOUND
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_INVALID
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_USED
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
case
'
INVITATION_CODE_DISABLED
'
:
return
t
(
'
auth.invitationCodeInvalid
'
)
default
:
return
t
(
'
auth.invitationCodeInvalid
'
)
}
}
// ==================== Turnstile Handlers ====================
function
onTurnstileVerify
(
token
:
string
):
void
{
...
...
@@ -429,6 +562,7 @@ function validateForm(): boolean {
errors
.
email
=
''
errors
.
password
=
''
errors
.
turnstile
=
''
errors
.
invitation_code
=
''
let
isValid
=
true
...
...
@@ -450,6 +584,14 @@ function validateForm(): boolean {
isValid
=
false
}
// Invitation code validation (required when enabled)
if
(
invitationCodeEnabled
.
value
)
{
if
(
!
formData
.
invitation_code
.
trim
())
{
errors
.
invitation_code
=
t
(
'
auth.invitationCodeRequired
'
)
isValid
=
false
}
}
// Turnstile validation
if
(
turnstileEnabled
.
value
&&
!
turnstileToken
.
value
)
{
errors
.
turnstile
=
t
(
'
auth.completeVerification
'
)
...
...
@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> {
}
}
// Check invitation code validation status (if enabled and code provided)
if
(
invitationCodeEnabled
.
value
)
{
// If still validating, wait
if
(
invitationValidating
.
value
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeValidating
'
)
return
}
// If invitation code is invalid, block submission
if
(
invitationValidation
.
invalid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeInvalidCannotRegister
'
)
return
}
// If invitation code is required but not validated yet
if
(
formData
.
invitation_code
.
trim
()
&&
!
invitationValidation
.
valid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeValidating
'
)
// Trigger validation
await
validateInvitationCodeDebounced
(
formData
.
invitation_code
.
trim
())
if
(
!
invitationValidation
.
valid
)
{
errorMessage
.
value
=
t
(
'
auth.invitationCodeInvalidCannotRegister
'
)
return
}
}
}
isLoading
.
value
=
true
try
{
...
...
@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileToken
.
value
,
promo_code
:
formData
.
promo_code
||
undefined
promo_code
:
formData
.
promo_code
||
undefined
,
invitation_code
:
formData
.
invitation_code
||
undefined
}
)
)
...
...
@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
email
:
formData
.
email
,
password
:
formData
.
password
,
turnstile_token
:
turnstileEnabled
.
value
?
turnstileToken
.
value
:
undefined
,
promo_code
:
formData
.
promo_code
||
undefined
promo_code
:
formData
.
promo_code
||
undefined
,
invitation_code
:
formData
.
invitation_code
||
undefined
}
)
// Show success toast
...
...
frontend/src/views/auth/ResetPasswordView.vue
0 → 100644
View file @
377bffe2
<
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/setup/SetupWizardView.vue
View file @
377bffe2
...
...
@@ -225,6 +225,18 @@
</div>
</div>
<div
class=
"flex items-center justify-between rounded-xl border border-gray-200 p-3 dark:border-dark-700"
>
<div>
<p
class=
"text-sm font-medium text-gray-900 dark:text-white"
>
{{ t("setup.redis.enableTls") }}
</p>
<p
class=
"text-xs text-gray-500 dark:text-dark-400"
>
{{ t("setup.redis.enableTlsHint") }}
</p>
</div>
<Toggle
v-model=
"formData.redis.enable_tls"
/>
</div>
<button
@
click=
"testRedisConnection"
:disabled=
"testingRedis"
...
...
@@ -470,6 +482,7 @@ import { ref, reactive, computed } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
testDatabase
,
testRedis
,
install
,
type
InstallRequest
}
from
'
@/api/setup
'
import
Select
from
'
@/components/common/Select.vue
'
import
Toggle
from
'
@/components/common/Toggle.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
...
...
@@ -517,7 +530,8 @@ const formData = reactive<InstallRequest>({
host
:
'
localhost
'
,
port
:
6379
,
password
:
''
,
db
:
0
db
:
0
,
enable_tls
:
false
},
admin
:
{
email
:
''
,
...
...
frontend/src/views/user/ProfileView.vue
View file @
377bffe2
...
...
@@ -15,6 +15,7 @@
</div>
<ProfileEditForm
:initial-username=
"user?.username || ''"
/>
<ProfilePasswordForm
/>
<ProfileTotpCard
/>
</div>
</AppLayout>
</
template
>
...
...
@@ -27,6 +28,7 @@ import StatCard from '@/components/common/StatCard.vue'
import
ProfileInfoCard
from
'
@/components/user/profile/ProfileInfoCard.vue
'
import
ProfileEditForm
from
'
@/components/user/profile/ProfileEditForm.vue
'
import
ProfilePasswordForm
from
'
@/components/user/profile/ProfilePasswordForm.vue
'
import
ProfileTotpCard
from
'
@/components/user/profile/ProfileTotpCard.vue
'
import
{
Icon
}
from
'
@/components/icons
'
const
{
t
}
=
useI18n
();
const
authStore
=
useAuthStore
();
const
user
=
computed
(()
=>
authStore
.
user
)
...
...
frontend/src/views/user/PurchaseSubscriptionView.vue
0 → 100644
View file @
377bffe2
<
template
>
<AppLayout>
<div
class=
"purchase-page-layout"
>
<div
class=
"flex items-start justify-between gap-4"
>
<div>
<h2
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.title
'
)
}}
</h2>
<p
class=
"mt-1 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.description
'
)
}}
</p>
</div>
<div
class=
"flex items-center gap-2"
>
<a
v-if=
"isValidUrl"
:href=
"purchaseUrl"
target=
"_blank"
rel=
"noopener noreferrer"
class=
"btn btn-secondary btn-sm"
>
<Icon
name=
"externalLink"
size=
"sm"
class=
"mr-1.5"
:stroke-width=
"2"
/>
{{
t
(
'
purchase.openInNewTab
'
)
}}
</a>
</div>
</div>
<div
class=
"card flex-1 min-h-0 overflow-hidden"
>
<div
v-if=
"loading"
class=
"flex h-full items-center justify-center py-12"
>
<div
class=
"h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
></div>
</div>
<div
v-else-if=
"!purchaseEnabled"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"creditCard"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.notEnabledTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.notEnabledDesc
'
)
}}
</p>
</div>
</div>
<div
v-else-if=
"!isValidUrl"
class=
"flex h-full items-center justify-center p-10 text-center"
>
<div
class=
"max-w-md"
>
<div
class=
"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
>
<Icon
name=
"link"
size=
"lg"
class=
"text-gray-400"
/>
</div>
<h3
class=
"text-lg font-semibold text-gray-900 dark:text-white"
>
{{
t
(
'
purchase.notConfiguredTitle
'
)
}}
</h3>
<p
class=
"mt-2 text-sm text-gray-500 dark:text-dark-400"
>
{{
t
(
'
purchase.notConfiguredDesc
'
)
}}
</p>
</div>
</div>
<iframe
v-else
:src=
"purchaseUrl"
class=
"h-full w-full border-0"
allowfullscreen
></iframe>
</div>
</div>
</AppLayout>
</
template
>
<
script
setup
lang=
"ts"
>
import
{
computed
,
onMounted
,
ref
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useAppStore
}
from
'
@/stores
'
import
AppLayout
from
'
@/components/layout/AppLayout.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
const
loading
=
ref
(
false
)
const
purchaseEnabled
=
computed
(()
=>
{
return
appStore
.
cachedPublicSettings
?.
purchase_subscription_enabled
??
false
})
const
purchaseUrl
=
computed
(()
=>
{
return
(
appStore
.
cachedPublicSettings
?.
purchase_subscription_url
||
''
).
trim
()
})
const
isValidUrl
=
computed
(()
=>
{
const
url
=
purchaseUrl
.
value
return
url
.
startsWith
(
'
http://
'
)
||
url
.
startsWith
(
'
https://
'
)
})
onMounted
(
async
()
=>
{
if
(
appStore
.
publicSettingsLoaded
)
return
loading
.
value
=
true
try
{
await
appStore
.
fetchPublicSettings
()
}
finally
{
loading
.
value
=
false
}
})
</
script
>
<
style
scoped
>
.purchase-page-layout
{
@apply
flex
flex-col
gap-6;
height
:
calc
(
100vh
-
64px
-
4rem
);
/* 减去 header + lg:p-8 的上下padding */
}
</
style
>
frontend/src/views/user/RedeemView.vue
View file @
377bffe2
...
...
@@ -312,6 +312,14 @@
<
p
v
-
else
class
=
"
text-xs text-gray-400 dark:text-dark-500
"
>
{{
t
(
'
redeem.adminAdjustment
'
)
}}
<
/p
>
<!--
Display
notes
for
admin
adjustments
-->
<
p
v
-
if
=
"
item.notes
"
class
=
"
mt-1 text-xs text-gray-500 dark:text-dark-400 italic max-w-[200px] truncate
"
:
title
=
"
item.notes
"
>
{{
item
.
notes
}}
<
/p
>
<
/div
>
<
/div
>
<
/div
>
...
...
frontend/src/views/user/UsageView.vue
View file @
377bffe2
...
...
@@ -157,6 +157,12 @@
<span
class=
"font-medium text-gray-900 dark:text-white"
>
{{
value
}}
</span>
</
template
>
<
template
#cell-reasoning_effort=
"{ row }"
>
<span
class=
"text-sm text-gray-900 dark:text-white"
>
{{
formatReasoningEffort
(
row
.
reasoning_effort
)
}}
</span>
</
template
>
<
template
#cell-stream=
"{ row }"
>
<span
class=
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium"
...
...
@@ -438,12 +444,12 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import
DataTable
from
'
@/components/common/DataTable.vue
'
import
Pagination
from
'
@/components/common/Pagination.vue
'
import
EmptyState
from
'
@/components/common/EmptyState.vue
'
import
Select
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
UsageLog
,
ApiKey
,
UsageQueryParams
,
UsageStatsResponse
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateTime
}
from
'
@/utils/format
'
import
Select
from
'
@/components/common/Select.vue
'
import
DateRangePicker
from
'
@/components/common/DateRangePicker.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
UsageLog
,
ApiKey
,
UsageQueryParams
,
UsageStatsResponse
}
from
'
@/types
'
import
type
{
Column
}
from
'
@/components/common/types
'
import
{
formatDateTime
,
formatReasoningEffort
}
from
'
@/utils/format
'
const
{
t
}
=
useI18n
()
const
appStore
=
useAppStore
()
...
...
@@ -466,6 +472,7 @@ const usageStats = ref<UsageStatsResponse | null>(null)
const
columns
=
computed
<
Column
[]
>
(()
=>
[
{
key
:
'
api_key
'
,
label
:
t
(
'
usage.apiKeyFilter
'
),
sortable
:
false
},
{
key
:
'
model
'
,
label
:
t
(
'
usage.model
'
),
sortable
:
true
},
{
key
:
'
reasoning_effort
'
,
label
:
t
(
'
usage.reasoningEffort
'
),
sortable
:
false
},
{
key
:
'
stream
'
,
label
:
t
(
'
usage.type
'
),
sortable
:
false
},
{
key
:
'
tokens
'
,
label
:
t
(
'
usage.tokens
'
),
sortable
:
false
},
{
key
:
'
cost
'
,
label
:
t
(
'
usage.cost
'
),
sortable
:
false
},
...
...
@@ -723,6 +730,7 @@ const exportToCSV = async () => {
'
Time
'
,
'
API Key Name
'
,
'
Model
'
,
'
Reasoning Effort
'
,
'
Type
'
,
'
Input Tokens
'
,
'
Output Tokens
'
,
...
...
@@ -739,6 +747,7 @@ const exportToCSV = async () => {
log
.
created_at
,
log
.
api_key
?.
name
||
''
,
log
.
model
,
formatReasoningEffort
(
log
.
reasoning_effort
),
log
.
stream
?
'
Stream
'
:
'
Sync
'
,
log
.
input_tokens
,
log
.
output_tokens
,
...
...
Prev
1
…
8
9
10
11
12
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