Commit 31fe0178 authored by yangjianbo's avatar yangjianbo
Browse files
parents d9e345f2 ba5a0d47
...@@ -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: true sortable: false
}, },
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true }, { key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false }, { key: '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 newDaysRemaining = currentDaysRemaining + extendForm.days const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
if (newDaysRemaining <= 0) { if (newExpiresAt <= new Date()) {
appStore.showError(t('admin.subscriptions.adjustWouldExpire')) appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
return return
} }
......
...@@ -35,12 +35,13 @@ ...@@ -35,12 +35,13 @@
<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 { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { useAppStore } from '@/stores/app'; import { adminAPI } from '@/api/admin'; import { adminUsageAPI } from '@/api/admin/usage' 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 { formatReasoningEffort } from '@/utils/format'
import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue' import AppLayout from '@/components/layout/AppLayout.vue'; import Pagination from '@/components/common/Pagination.vue'; import Select from '@/components/common/Select.vue'
import UsageTable from '@/components/admin/usage/UsageTable.vue'; import UsageExportProgress from '@/components/admin/usage/UsageExportProgress.vue' import UsageStatsCards from '@/components/admin/usage/UsageStatsCards.vue'; import UsageFilters from '@/components/admin/usage/UsageFilters.vue'
import UsageCleanupDialog from '@/components/admin/usage/UsageCleanupDialog.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 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' import type { AdminUsageLog, TrendDataPoint, ModelStat } from '@/types'; import type { AdminUsageStatsResponse, AdminUsageQueryParams } from '@/api/admin/usage'
...@@ -104,7 +105,7 @@ const exportToExcel = async () => { ...@@ -104,7 +105,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const headers = [ const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), 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('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'), t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
...@@ -120,6 +121,7 @@ const exportToExcel = async () => { ...@@ -120,6 +121,7 @@ const exportToExcel = async () => {
log.api_key?.name || '', log.api_key?.name || '',
log.account?.name || '', log.account?.name || '',
log.model, log.model,
formatReasoningEffort(log.reasoning_effort),
log.group?.name || '', log.group?.name || '',
log.stream ? t('usage.stream') : t('usage.sync'), log.stream ? t('usage.stream') : t('usage.sync'),
log.input_tokens, log.input_tokens,
......
...@@ -300,8 +300,29 @@ ...@@ -300,8 +300,29 @@
</span> </span>
</template> </template>
<template #cell-balance="{ value }"> <template #cell-balance="{ value, row }">
<span class="font-medium text-gray-900 dark:text-white">${{ value.toFixed(2) }}</span> <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>
<template #cell-usage="{ row }"> <template #cell-usage="{ row }">
...@@ -456,6 +477,15 @@ ...@@ -456,6 +477,15 @@
{{ t('admin.users.withdraw') }} {{ t('admin.users.withdraw') }}
</button> </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> <div class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<!-- Delete (not for admin) --> <!-- Delete (not for admin) -->
...@@ -479,6 +509,7 @@ ...@@ -479,6 +509,7 @@
<UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" /> <UserApiKeysModal :show="showApiKeysModal" :user="viewingUser" @close="closeApiKeysModal" />
<UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" /> <UserAllowedGroupsModal :show="showAllowedGroupsModal" :user="allowedGroupsUser" @close="closeAllowedGroupsModal" @success="loadUsers" />
<UserBalanceModal :show="showBalanceModal" :user="balanceUser" :operation="balanceOperation" @close="closeBalanceModal" @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" /> <UserAttributesConfigModal :show="showAttributesModal" @close="handleAttributesModalClose" />
</AppLayout> </AppLayout>
</template> </template>
...@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue' ...@@ -509,6 +540,7 @@ import UserEditModal from '@/components/admin/user/UserEditModal.vue'
import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue' import UserApiKeysModal from '@/components/admin/user/UserApiKeysModal.vue'
import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue' import UserAllowedGroupsModal from '@/components/admin/user/UserAllowedGroupsModal.vue'
import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue' import UserBalanceModal from '@/components/admin/user/UserBalanceModal.vue'
import UserBalanceHistoryModal from '@/components/admin/user/UserBalanceHistoryModal.vue'
const appStore = useAppStore() const appStore = useAppStore()
...@@ -828,6 +860,10 @@ const showBalanceModal = ref(false) ...@@ -828,6 +860,10 @@ const showBalanceModal = ref(false)
const balanceUser = ref<AdminUser | null>(null) const balanceUser = ref<AdminUser | null>(null)
const balanceOperation = ref<'add' | 'subtract'>('add') 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 getDaysRemaining = (expiresAt: string): number => {
const now = new Date() const now = new Date()
...@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => { ...@@ -1078,6 +1114,30 @@ const closeBalanceModal = () => {
balanceUser.value = null 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 = () => { const handleScroll = () => {
closeActionMenu() closeActionMenu()
......
...@@ -49,6 +49,7 @@ interface SummaryRow { ...@@ -49,6 +49,7 @@ interface SummaryRow {
total_accounts: number total_accounts: number
available_accounts: number available_accounts: number
rate_limited_accounts: number rate_limited_accounts: number
scope_rate_limit_count?: Record<string, number>
error_accounts: number error_accounts: number
// 并发统计 // 并发统计
total_concurrency: number total_concurrency: number
...@@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => { ...@@ -102,6 +103,7 @@ const platformRows = computed((): SummaryRow[] => {
total_accounts: totalAccounts, total_accounts: totalAccounts,
available_accounts: availableAccounts, available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count), rate_limited_accounts: safeNumber(avail.rate_limit_count),
scope_rate_limit_count: avail.scope_rate_limit_count,
error_accounts: safeNumber(avail.error_count), error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency, total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency, used_concurrency: usedConcurrency,
...@@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => { ...@@ -141,6 +143,7 @@ const groupRows = computed((): SummaryRow[] => {
total_accounts: totalAccounts, total_accounts: totalAccounts,
available_accounts: availableAccounts, available_accounts: availableAccounts,
rate_limited_accounts: safeNumber(avail.rate_limit_count), rate_limited_accounts: safeNumber(avail.rate_limit_count),
scope_rate_limit_count: avail.scope_rate_limit_count,
error_accounts: safeNumber(avail.error_count), error_accounts: safeNumber(avail.error_count),
total_concurrency: totalConcurrency, total_concurrency: totalConcurrency,
used_concurrency: usedConcurrency, used_concurrency: usedConcurrency,
...@@ -269,6 +272,15 @@ function formatDuration(seconds: number): string { ...@@ -269,6 +272,15 @@ function formatDuration(seconds: number): string {
return `${hours}h` 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( watch(
() => realtimeEnabled.value, () => realtimeEnabled.value,
async (enabled) => { async (enabled) => {
...@@ -387,6 +399,18 @@ watch( ...@@ -387,6 +399,18 @@ watch(
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }} {{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
</span> </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 <span
v-if="row.error_accounts > 0" v-if="row.error_accounts > 0"
......
...@@ -505,6 +505,16 @@ async function saveAllSettings() { ...@@ -505,6 +505,16 @@ async function saveAllSettings() {
</div> </div>
<Toggle v-model="advancedSettings.ignore_no_available_accounts" /> <Toggle v-model="advancedSettings.ignore_no_available_accounts" />
</div> </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> </div>
<!-- Auto Refresh --> <!-- Auto Refresh -->
......
...@@ -201,6 +201,7 @@ const email = ref<string>('') ...@@ -201,6 +201,7 @@ const email = ref<string>('')
const password = ref<string>('') const password = ref<string>('')
const initialTurnstileToken = ref<string>('') const initialTurnstileToken = ref<string>('')
const promoCode = ref<string>('') const promoCode = ref<string>('')
const invitationCode = ref<string>('')
const hasRegisterData = ref<boolean>(false) const hasRegisterData = ref<boolean>(false)
// Public settings // Public settings
...@@ -230,6 +231,7 @@ onMounted(async () => { ...@@ -230,6 +231,7 @@ onMounted(async () => {
password.value = registerData.password || '' password.value = registerData.password || ''
initialTurnstileToken.value = registerData.turnstile_token || '' initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || '' promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || ''
hasRegisterData.value = !!(email.value && password.value) hasRegisterData.value = !!(email.value && password.value)
} catch { } catch {
hasRegisterData.value = false hasRegisterData.value = false
...@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> { ...@@ -384,7 +386,8 @@ async function handleVerify(): Promise<void> {
password: password.value, password: password.value,
verify_code: verifyCode.value.trim(), verify_code: verifyCode.value.trim(),
turnstile_token: initialTurnstileToken.value || undefined, turnstile_token: initialTurnstileToken.value || undefined,
promo_code: promoCode.value || undefined promo_code: promoCode.value || undefined,
invitation_code: invitationCode.value || undefined
}) })
// Clear session data // Clear session data
......
<template>
<AuthLayout>
<div class="space-y-6">
<!-- Title -->
<div class="text-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{{ t('auth.forgotPasswordTitle') }}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ t('auth.forgotPasswordHint') }}
</p>
</div>
<!-- Success State -->
<div v-if="isSubmitted" class="space-y-6">
<div class="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-800/50 dark:bg-green-900/20">
<div class="flex flex-col items-center gap-4 text-center">
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-800/50">
<Icon name="checkCircle" size="lg" class="text-green-600 dark:text-green-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
{{ t('auth.resetEmailSent') }}
</h3>
<p class="mt-2 text-sm text-green-700 dark:text-green-300">
{{ t('auth.resetEmailSentHint') }}
</p>
</div>
</div>
</div>
<div class="text-center">
<router-link
to="/login"
class="inline-flex items-center gap-2 font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
<Icon name="arrowLeft" size="sm" />
{{ t('auth.backToLogin') }}
</router-link>
</div>
</div>
<!-- Form State -->
<form v-else @submit.prevent="handleSubmit" class="space-y-5">
<!-- Email Input -->
<div>
<label for="email" class="input-label">
{{ t('auth.emailLabel') }}
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
<Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" />
</div>
<input
id="email"
v-model="formData.email"
type="email"
required
autofocus
autocomplete="email"
:disabled="isLoading"
class="input pl-11"
:class="{ 'input-error': errors.email }"
:placeholder="t('auth.emailPlaceholder')"
/>
</div>
<p v-if="errors.email" class="input-error-text">
{{ errors.email }}
</p>
</div>
<!-- Turnstile Widget -->
<div v-if="turnstileEnabled && turnstileSiteKey">
<TurnstileWidget
ref="turnstileRef"
:site-key="turnstileSiteKey"
@verify="onTurnstileVerify"
@expire="onTurnstileExpire"
@error="onTurnstileError"
/>
<p v-if="errors.turnstile" class="input-error-text mt-2 text-center">
{{ errors.turnstile }}
</p>
</div>
<!-- Error Message -->
<transition name="fade">
<div
v-if="errorMessage"
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<Icon name="exclamationCircle" size="md" class="text-red-500" />
</div>
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage }}
</p>
</div>
</div>
</transition>
<!-- Submit Button -->
<button
type="submit"
:disabled="isLoading || (turnstileEnabled && !turnstileToken)"
class="btn btn-primary w-full"
>
<svg
v-if="isLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="mail" size="md" class="mr-2" />
{{ isLoading ? t('auth.sendingResetLink') : t('auth.sendResetLink') }}
</button>
</form>
</div>
<!-- Footer -->
<template #footer>
<p class="text-gray-500 dark:text-dark-400">
{{ t('auth.rememberedPassword') }}
<router-link
to="/login"
class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
{{ t('auth.signIn') }}
</router-link>
</p>
</template>
</AuthLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { AuthLayout } from '@/components/layout'
import Icon from '@/components/icons/Icon.vue'
import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAppStore } from '@/stores'
import { getPublicSettings, forgotPassword } from '@/api/auth'
const { t } = useI18n()
// ==================== Stores ====================
const appStore = useAppStore()
// ==================== State ====================
const isLoading = ref<boolean>(false)
const isSubmitted = ref<boolean>(false)
const errorMessage = ref<string>('')
// Public settings
const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('')
// Turnstile
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
const turnstileToken = ref<string>('')
const formData = reactive({
email: ''
})
const errors = reactive({
email: '',
turnstile: ''
})
// ==================== Lifecycle ====================
onMounted(async () => {
try {
const settings = await getPublicSettings()
turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || ''
} catch (error) {
console.error('Failed to load public settings:', error)
}
})
// ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void {
turnstileToken.value = token
errors.turnstile = ''
}
function onTurnstileExpire(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileExpired')
}
function onTurnstileError(): void {
turnstileToken.value = ''
errors.turnstile = t('auth.turnstileFailed')
}
// ==================== Validation ====================
function validateForm(): boolean {
errors.email = ''
errors.turnstile = ''
let isValid = true
// Email validation
if (!formData.email.trim()) {
errors.email = t('auth.emailRequired')
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
}
// Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification')
isValid = false
}
return isValid
}
// ==================== Form Handlers ====================
async function handleSubmit(): Promise<void> {
errorMessage.value = ''
if (!validateForm()) {
return
}
isLoading.value = true
try {
await forgotPassword({
email: formData.email,
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined
})
isSubmitted.value = true
appStore.showSuccess(t('auth.resetEmailSent'))
} catch (error: unknown) {
// Reset Turnstile on error
if (turnstileRef.value) {
turnstileRef.value.reset()
turnstileToken.value = ''
}
const err = error as { message?: string; response?: { data?: { detail?: string } } }
if (err.response?.data?.detail) {
errorMessage.value = err.response.data.detail
} else if (err.message) {
errorMessage.value = err.message
} else {
errorMessage.value = t('auth.sendResetLinkFailed')
}
appStore.showError(errorMessage.value)
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
...@@ -72,9 +72,19 @@ ...@@ -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>
......
...@@ -95,6 +95,59 @@ ...@@ -95,6 +95,59 @@
</p> </p>
</div> </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) --> <!-- Promo Code Input (Optional) -->
<div v-if="promoCodeEnabled"> <div v-if="promoCodeEnabled">
<label for="promo_code" class="input-label"> <label for="promo_code" class="input-label">
...@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' ...@@ -239,7 +292,7 @@ import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.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, validatePromoCode } from '@/api/auth' import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth'
const { t } = useI18n() const { t } = useI18n()
...@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false) ...@@ -261,6 +314,7 @@ const showPassword = ref<boolean>(false)
const registrationEnabled = ref<boolean>(true) const registrationEnabled = ref<boolean>(true)
const emailVerifyEnabled = ref<boolean>(false) const emailVerifyEnabled = ref<boolean>(false)
const promoCodeEnabled = ref<boolean>(true) const promoCodeEnabled = ref<boolean>(true)
const invitationCodeEnabled = ref<boolean>(false)
const turnstileEnabled = ref<boolean>(false) const turnstileEnabled = ref<boolean>(false)
const turnstileSiteKey = ref<string>('') const turnstileSiteKey = ref<string>('')
const siteName = ref<string>('Sub2API') const siteName = ref<string>('Sub2API')
...@@ -280,16 +334,27 @@ const promoValidation = reactive({ ...@@ -280,16 +334,27 @@ const promoValidation = reactive({
}) })
let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null 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({ const formData = reactive({
email: '', email: '',
password: '', password: '',
promo_code: '' promo_code: '',
invitation_code: ''
}) })
const errors = reactive({ const errors = reactive({
email: '', email: '',
password: '', password: '',
turnstile: '' turnstile: '',
invitation_code: ''
}) })
// ==================== Lifecycle ==================== // ==================== Lifecycle ====================
...@@ -300,6 +365,7 @@ onMounted(async () => { ...@@ -300,6 +365,7 @@ onMounted(async () => {
registrationEnabled.value = settings.registration_enabled registrationEnabled.value = settings.registration_enabled
emailVerifyEnabled.value = settings.email_verify_enabled emailVerifyEnabled.value = settings.email_verify_enabled
promoCodeEnabled.value = settings.promo_code_enabled promoCodeEnabled.value = settings.promo_code_enabled
invitationCodeEnabled.value = settings.invitation_code_enabled
turnstileEnabled.value = settings.turnstile_enabled turnstileEnabled.value = settings.turnstile_enabled
turnstileSiteKey.value = settings.turnstile_site_key || '' turnstileSiteKey.value = settings.turnstile_site_key || ''
siteName.value = settings.site_name || 'Sub2API' siteName.value = settings.site_name || 'Sub2API'
...@@ -325,6 +391,9 @@ onUnmounted(() => { ...@@ -325,6 +391,9 @@ onUnmounted(() => {
if (promoValidateTimeout) { if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout) clearTimeout(promoValidateTimeout)
} }
if (invitationValidateTimeout) {
clearTimeout(invitationValidateTimeout)
}
}) })
// ==================== Promo Code Validation ==================== // ==================== Promo Code Validation ====================
...@@ -400,6 +469,70 @@ function getPromoErrorMessage(errorCode?: string): string { ...@@ -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 ==================== // ==================== Turnstile Handlers ====================
function onTurnstileVerify(token: string): void { function onTurnstileVerify(token: string): void {
...@@ -429,6 +562,7 @@ function validateForm(): boolean { ...@@ -429,6 +562,7 @@ function validateForm(): boolean {
errors.email = '' errors.email = ''
errors.password = '' errors.password = ''
errors.turnstile = '' errors.turnstile = ''
errors.invitation_code = ''
let isValid = true let isValid = true
...@@ -450,6 +584,14 @@ function validateForm(): boolean { ...@@ -450,6 +584,14 @@ function validateForm(): boolean {
isValid = false 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 // Turnstile validation
if (turnstileEnabled.value && !turnstileToken.value) { if (turnstileEnabled.value && !turnstileToken.value) {
errors.turnstile = t('auth.completeVerification') errors.turnstile = t('auth.completeVerification')
...@@ -484,6 +626,30 @@ async function handleRegister(): Promise<void> { ...@@ -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 isLoading.value = true
try { try {
...@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> { ...@@ -496,7 +662,8 @@ async function handleRegister(): Promise<void> {
email: formData.email, email: formData.email,
password: formData.password, password: formData.password,
turnstile_token: turnstileToken.value, 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> { ...@@ -510,7 +677,8 @@ async function handleRegister(): Promise<void> {
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,
promo_code: formData.promo_code || undefined promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined
}) })
// Show success toast // Show success toast
......
<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>
...@@ -225,6 +225,18 @@ ...@@ -225,6 +225,18 @@
</div> </div>
</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 <button
@click="testRedisConnection" @click="testRedisConnection"
:disabled="testingRedis" :disabled="testingRedis"
...@@ -470,6 +482,7 @@ import { ref, reactive, computed } from 'vue' ...@@ -470,6 +482,7 @@ import { ref, reactive, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup' import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n() const { t } = useI18n()
...@@ -517,7 +530,8 @@ const formData = reactive<InstallRequest>({ ...@@ -517,7 +530,8 @@ const formData = reactive<InstallRequest>({
host: 'localhost', host: 'localhost',
port: 6379, port: 6379,
password: '', password: '',
db: 0 db: 0,
enable_tls: false
}, },
admin: { admin: {
email: '', email: '',
......
...@@ -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)
......
<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>
...@@ -312,6 +312,14 @@ ...@@ -312,6 +312,14 @@
<p v-else class="text-xs text-gray-400 dark:text-dark-500"> <p v-else class="text-xs text-gray-400 dark:text-dark-500">
{{ t('redeem.adminAdjustment') }} {{ t('redeem.adminAdjustment') }}
</p> </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> </div>
</div> </div>
......
...@@ -157,6 +157,12 @@ ...@@ -157,6 +157,12 @@
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template> </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 }"> <template #cell-stream="{ row }">
<span <span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" 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' ...@@ -438,12 +444,12 @@ import TablePageLayout from '@/components/layout/TablePageLayout.vue'
import DataTable from '@/components/common/DataTable.vue' import DataTable from '@/components/common/DataTable.vue'
import Pagination from '@/components/common/Pagination.vue' import Pagination from '@/components/common/Pagination.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue' import DateRangePicker from '@/components/common/DateRangePicker.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types' import type { UsageLog, ApiKey, UsageQueryParams, UsageStatsResponse } from '@/types'
import type { Column } from '@/components/common/types' import type { Column } from '@/components/common/types'
import { formatDateTime } from '@/utils/format' import { formatDateTime, formatReasoningEffort } from '@/utils/format'
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
...@@ -466,6 +472,7 @@ const usageStats = ref<UsageStatsResponse | null>(null) ...@@ -466,6 +472,7 @@ const usageStats = ref<UsageStatsResponse | null>(null)
const columns = computed<Column[]>(() => [ const columns = computed<Column[]>(() => [
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false }, { key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true }, { 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: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false }, { key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false }, { key: 'cost', label: t('usage.cost'), sortable: false },
...@@ -723,6 +730,7 @@ const exportToCSV = async () => { ...@@ -723,6 +730,7 @@ const exportToCSV = async () => {
'Time', 'Time',
'API Key Name', 'API Key Name',
'Model', 'Model',
'Reasoning Effort',
'Type', 'Type',
'Input Tokens', 'Input Tokens',
'Output Tokens', 'Output Tokens',
...@@ -739,6 +747,7 @@ const exportToCSV = async () => { ...@@ -739,6 +747,7 @@ const exportToCSV = async () => {
log.created_at, log.created_at,
log.api_key?.name || '', log.api_key?.name || '',
log.model, log.model,
formatReasoningEffort(log.reasoning_effort),
log.stream ? 'Stream' : 'Sync', log.stream ? 'Stream' : 'Sync',
log.input_tokens, log.input_tokens,
log.output_tokens, log.output_tokens,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment