feat: rebuild auth identity foundation flow
This commit is contained in:
60
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
Normal file
60
frontend/src/api/__tests__/auth-oauth-adoption.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const post = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post
|
||||
}
|
||||
}))
|
||||
|
||||
describe('oauth adoption auth api', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
||||
const { exchangePendingOAuthCompletion } = await import('@/api/auth')
|
||||
|
||||
await exchangePendingOAuthCompletion({
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts linuxdo invitation completion with adoption decisions', async () => {
|
||||
const { completeLinuxDoOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeLinuxDoOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false
|
||||
})
|
||||
})
|
||||
|
||||
it('posts oidc invitation completion with adoption decisions', async () => {
|
||||
const { completeOIDCOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeOIDCOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
})
|
||||
118
frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts
Normal file
118
frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
type UpdateSettingsRequest,
|
||||
} from '@/api/admin/settings'
|
||||
|
||||
describe('admin settings auth source defaults helpers', () => {
|
||||
it('builds auth source defaults state from flat settings fields', () => {
|
||||
const state = buildAuthSourceDefaultsState({
|
||||
auth_source_default_email_balance: 9.5,
|
||||
auth_source_default_email_concurrency: 3,
|
||||
auth_source_default_email_subscriptions: [
|
||||
{ group_id: 1, validity_days: 30 },
|
||||
],
|
||||
auth_source_default_email_grant_on_signup: false,
|
||||
auth_source_default_email_grant_on_first_bind: true,
|
||||
auth_source_default_linuxdo_balance: 6,
|
||||
auth_source_default_linuxdo_concurrency: 8,
|
||||
auth_source_default_linuxdo_subscriptions: [
|
||||
{ group_id: 2, validity_days: 60 },
|
||||
],
|
||||
auth_source_default_linuxdo_grant_on_signup: true,
|
||||
auth_source_default_linuxdo_grant_on_first_bind: false,
|
||||
})
|
||||
|
||||
expect(state.email).toEqual({
|
||||
balance: 9.5,
|
||||
concurrency: 3,
|
||||
subscriptions: [{ group_id: 1, validity_days: 30 }],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
})
|
||||
expect(state.linuxdo).toEqual({
|
||||
balance: 6,
|
||||
concurrency: 8,
|
||||
subscriptions: [{ group_id: 2, validity_days: 60 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
})
|
||||
expect(state.oidc).toEqual({
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
})
|
||||
expect(state.wechat).toEqual({
|
||||
balance: 0,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('appends auth source defaults back onto update payload', () => {
|
||||
const payload: UpdateSettingsRequest = {
|
||||
site_name: 'Sub2API',
|
||||
}
|
||||
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, {
|
||||
email: {
|
||||
balance: 1.25,
|
||||
concurrency: 2,
|
||||
subscriptions: [{ group_id: 3, validity_days: 7 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: false,
|
||||
},
|
||||
linuxdo: {
|
||||
balance: 0,
|
||||
concurrency: 6,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: true,
|
||||
},
|
||||
oidc: {
|
||||
balance: 4,
|
||||
concurrency: 9,
|
||||
subscriptions: [{ group_id: 9, validity_days: 90 }],
|
||||
grant_on_signup: true,
|
||||
grant_on_first_bind: true,
|
||||
},
|
||||
wechat: {
|
||||
balance: 2,
|
||||
concurrency: 5,
|
||||
subscriptions: [],
|
||||
grant_on_signup: false,
|
||||
grant_on_first_bind: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
site_name: 'Sub2API',
|
||||
auth_source_default_email_balance: 1.25,
|
||||
auth_source_default_email_concurrency: 2,
|
||||
auth_source_default_email_subscriptions: [{ group_id: 3, validity_days: 7 }],
|
||||
auth_source_default_email_grant_on_signup: true,
|
||||
auth_source_default_email_grant_on_first_bind: false,
|
||||
auth_source_default_linuxdo_balance: 0,
|
||||
auth_source_default_linuxdo_concurrency: 6,
|
||||
auth_source_default_linuxdo_subscriptions: [],
|
||||
auth_source_default_linuxdo_grant_on_signup: false,
|
||||
auth_source_default_linuxdo_grant_on_first_bind: true,
|
||||
auth_source_default_oidc_balance: 4,
|
||||
auth_source_default_oidc_concurrency: 9,
|
||||
auth_source_default_oidc_subscriptions: [{ group_id: 9, validity_days: 90 }],
|
||||
auth_source_default_oidc_grant_on_signup: true,
|
||||
auth_source_default_oidc_grant_on_first_bind: true,
|
||||
auth_source_default_wechat_balance: 2,
|
||||
auth_source_default_wechat_concurrency: 5,
|
||||
auth_source_default_wechat_subscriptions: [],
|
||||
auth_source_default_wechat_grant_on_signup: false,
|
||||
auth_source_default_wechat_grant_on_first_bind: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,81 @@ export interface DefaultSubscriptionSetting {
|
||||
validity_days: number
|
||||
}
|
||||
|
||||
export type AuthSourceType = 'email' | 'linuxdo' | 'oidc' | 'wechat'
|
||||
|
||||
export interface AuthSourceDefaultsValue {
|
||||
balance: number
|
||||
concurrency: number
|
||||
subscriptions: DefaultSubscriptionSetting[]
|
||||
grant_on_signup: boolean
|
||||
grant_on_first_bind: boolean
|
||||
}
|
||||
|
||||
export type AuthSourceDefaultsState = Record<AuthSourceType, AuthSourceDefaultsValue>
|
||||
|
||||
const AUTH_SOURCE_TYPES: AuthSourceType[] = ['email', 'linuxdo', 'oidc', 'wechat']
|
||||
const AUTH_SOURCE_DEFAULT_BALANCE = 0
|
||||
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5
|
||||
|
||||
export function normalizeDefaultSubscriptionSettings(
|
||||
subscriptions: DefaultSubscriptionSetting[] | null | undefined
|
||||
): DefaultSubscriptionSetting[] {
|
||||
if (!Array.isArray(subscriptions)) return []
|
||||
|
||||
return subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item) => ({
|
||||
group_id: Math.floor(item.group_id),
|
||||
validity_days: Math.min(36500, Math.max(1, Math.floor(item.validity_days)))
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildAuthSourceDefaultsState(
|
||||
settings: Partial<SystemSettings>
|
||||
): AuthSourceDefaultsState {
|
||||
const raw = settings as Record<string, unknown>
|
||||
|
||||
return AUTH_SOURCE_TYPES.reduce((acc, source) => {
|
||||
const subscriptions = raw[`auth_source_default_${source}_subscriptions`]
|
||||
acc[source] = {
|
||||
balance: Number(raw[`auth_source_default_${source}_balance`] ?? AUTH_SOURCE_DEFAULT_BALANCE),
|
||||
concurrency: Math.max(
|
||||
1,
|
||||
Number(raw[`auth_source_default_${source}_concurrency`] ?? AUTH_SOURCE_DEFAULT_CONCURRENCY)
|
||||
),
|
||||
subscriptions: normalizeDefaultSubscriptionSettings(
|
||||
Array.isArray(subscriptions) ? (subscriptions as DefaultSubscriptionSetting[]) : []
|
||||
),
|
||||
grant_on_signup: raw[`auth_source_default_${source}_grant_on_signup`] !== false,
|
||||
grant_on_first_bind: raw[`auth_source_default_${source}_grant_on_first_bind`] === true,
|
||||
}
|
||||
return acc
|
||||
}, {} as AuthSourceDefaultsState)
|
||||
}
|
||||
|
||||
export function appendAuthSourceDefaultsToUpdateRequest(
|
||||
payload: UpdateSettingsRequest,
|
||||
authSourceDefaults: AuthSourceDefaultsState
|
||||
): UpdateSettingsRequest {
|
||||
const target = payload as Record<string, unknown>
|
||||
|
||||
for (const source of AUTH_SOURCE_TYPES) {
|
||||
const current = authSourceDefaults[source]
|
||||
target[`auth_source_default_${source}_balance`] = Number(current.balance) || 0
|
||||
target[`auth_source_default_${source}_concurrency`] = Math.max(
|
||||
1,
|
||||
Math.floor(Number(current.concurrency) || AUTH_SOURCE_DEFAULT_CONCURRENCY)
|
||||
)
|
||||
target[`auth_source_default_${source}_subscriptions`] = normalizeDefaultSubscriptionSettings(
|
||||
current.subscriptions
|
||||
)
|
||||
target[`auth_source_default_${source}_grant_on_signup`] = current.grant_on_signup
|
||||
target[`auth_source_default_${source}_grant_on_first_bind`] = current.grant_on_first_bind
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* System settings interface
|
||||
*/
|
||||
@@ -29,6 +104,27 @@ export interface SystemSettings {
|
||||
default_balance: number
|
||||
default_concurrency: number
|
||||
default_subscriptions: DefaultSubscriptionSetting[]
|
||||
auth_source_default_email_balance?: number
|
||||
auth_source_default_email_concurrency?: number
|
||||
auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_email_grant_on_signup?: boolean
|
||||
auth_source_default_email_grant_on_first_bind?: boolean
|
||||
auth_source_default_linuxdo_balance?: number
|
||||
auth_source_default_linuxdo_concurrency?: number
|
||||
auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_linuxdo_grant_on_signup?: boolean
|
||||
auth_source_default_linuxdo_grant_on_first_bind?: boolean
|
||||
auth_source_default_oidc_balance?: number
|
||||
auth_source_default_oidc_concurrency?: number
|
||||
auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_oidc_grant_on_signup?: boolean
|
||||
auth_source_default_oidc_grant_on_first_bind?: boolean
|
||||
auth_source_default_wechat_balance?: number
|
||||
auth_source_default_wechat_concurrency?: number
|
||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_wechat_grant_on_signup?: boolean
|
||||
auth_source_default_wechat_grant_on_first_bind?: boolean
|
||||
force_email_on_third_party_signup?: boolean
|
||||
// OEM settings
|
||||
site_name: string
|
||||
site_logo: string
|
||||
@@ -137,6 +233,11 @@ export interface SystemSettings {
|
||||
payment_cancel_rate_limit_window: number
|
||||
payment_cancel_rate_limit_unit: string
|
||||
payment_cancel_rate_limit_window_mode: string
|
||||
payment_visible_method_alipay_source?: string
|
||||
payment_visible_method_wxpay_source?: string
|
||||
payment_visible_method_alipay_enabled?: boolean
|
||||
payment_visible_method_wxpay_enabled?: boolean
|
||||
openai_advanced_scheduler_enabled?: boolean
|
||||
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: boolean
|
||||
@@ -158,6 +259,27 @@ export interface UpdateSettingsRequest {
|
||||
default_balance?: number
|
||||
default_concurrency?: number
|
||||
default_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_email_balance?: number
|
||||
auth_source_default_email_concurrency?: number
|
||||
auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_email_grant_on_signup?: boolean
|
||||
auth_source_default_email_grant_on_first_bind?: boolean
|
||||
auth_source_default_linuxdo_balance?: number
|
||||
auth_source_default_linuxdo_concurrency?: number
|
||||
auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_linuxdo_grant_on_signup?: boolean
|
||||
auth_source_default_linuxdo_grant_on_first_bind?: boolean
|
||||
auth_source_default_oidc_balance?: number
|
||||
auth_source_default_oidc_concurrency?: number
|
||||
auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_oidc_grant_on_signup?: boolean
|
||||
auth_source_default_oidc_grant_on_first_bind?: boolean
|
||||
auth_source_default_wechat_balance?: number
|
||||
auth_source_default_wechat_concurrency?: number
|
||||
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]
|
||||
auth_source_default_wechat_grant_on_signup?: boolean
|
||||
auth_source_default_wechat_grant_on_first_bind?: boolean
|
||||
force_email_on_third_party_signup?: boolean
|
||||
site_name?: string
|
||||
site_logo?: string
|
||||
site_subtitle?: string
|
||||
@@ -245,6 +367,11 @@ export interface UpdateSettingsRequest {
|
||||
payment_cancel_rate_limit_window?: number
|
||||
payment_cancel_rate_limit_unit?: string
|
||||
payment_cancel_rate_limit_window_mode?: string
|
||||
payment_visible_method_alipay_source?: string
|
||||
payment_visible_method_wxpay_source?: string
|
||||
payment_visible_method_alipay_enabled?: boolean
|
||||
payment_visible_method_wxpay_enabled?: boolean
|
||||
openai_advanced_scheduler_enabled?: boolean
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled?: boolean
|
||||
balance_low_notify_threshold?: number
|
||||
|
||||
@@ -198,6 +198,26 @@ export interface PendingOAuthExchangeResponse {
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
export interface OAuthAdoptionDecision {
|
||||
adoptDisplayName?: boolean
|
||||
adoptAvatar?: boolean
|
||||
}
|
||||
|
||||
function serializeOAuthAdoptionDecision(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Record<string, boolean> {
|
||||
const payload: Record<string, boolean> = {}
|
||||
|
||||
if (typeof decision?.adoptDisplayName === 'boolean') {
|
||||
payload.adopt_display_name = decision.adoptDisplayName
|
||||
}
|
||||
if (typeof decision?.adoptAvatar === 'boolean') {
|
||||
payload.adopt_avatar = decision.adoptAvatar
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
* @returns New token pair
|
||||
@@ -353,7 +373,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeLinuxDoOAuthRegistration(
|
||||
invitationCode: string
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
@@ -361,7 +382,8 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: invitationCode
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -372,7 +394,8 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
* @returns Token pair on success
|
||||
*/
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
invitationCode: string
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
@@ -380,13 +403,19 @@ export async function completeOIDCOAuthRegistration(
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: invitationCode
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangePendingOAuthCompletion(): Promise<PendingOAuthExchangeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>('/auth/oauth/pending/exchange', {})
|
||||
export async function exchangePendingOAuthCompletion(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<PendingOAuthExchangeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
||||
'/auth/oauth/pending/exchange',
|
||||
serializeOAuthAdoptionDecision(decision)
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
53
frontend/src/components/auth/WechatOAuthSection.vue
Normal file
53
frontend/src/components/auth/WechatOAuthSection.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||
<span
|
||||
class="mr-2 inline-flex h-5 w-5 items-center justify-center rounded-full bg-green-100 text-xs font-semibold text-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
>
|
||||
W
|
||||
</span>
|
||||
{{ t('auth.oidc.signIn', { providerName }) }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
const providerName = 'WeChat'
|
||||
|
||||
function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'open'
|
||||
}
|
||||
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||
}
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const mode = resolveWeChatOAuthMode()
|
||||
const startURL = `${normalized}/auth/oauth/wechat/start?mode=${mode}&redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
|
||||
const routeState = vi.hoisted(() => ({
|
||||
query: {} as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
const locationState = vi.hoisted(() => ({
|
||||
current: { href: 'http://localhost/login' } as { href: string },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oidc.signIn') {
|
||||
return `Continue with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oauthOrContinue') {
|
||||
return 'or continue'
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WechatOAuthSection', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = { redirect: '/billing?plan=pro' }
|
||||
locationState.current = { href: 'http://localhost/login' }
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: locationState.current,
|
||||
})
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('starts the open WeChat OAuth flow with the current redirect target', async () => {
|
||||
const wrapper = mount(WechatOAuthSection)
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain(
|
||||
'/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses mp mode inside the WeChat browser', async () => {
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: 'Mozilla/5.0 MicroMessenger',
|
||||
})
|
||||
const wrapper = mount(WechatOAuthSection)
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(locationState.current.href).toContain(
|
||||
'/api/v1/auth/oauth/wechat/start?mode=mp&redirect=%2Fbilling%3Fplan%3Dpro'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -141,7 +141,9 @@ const props = defineProps<{
|
||||
orderType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ done: []; success: [] }>()
|
||||
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
|
||||
|
||||
const emit = defineEmits<{ done: []; success: []; settled: [outcome: PaymentOutcome] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const paymentStore = usePaymentStore()
|
||||
@@ -154,7 +156,7 @@ const cancelling = ref(false)
|
||||
const paidOrder = ref<PaymentOrder | null>(null)
|
||||
|
||||
// Terminal outcome: null = still active, 'success' | 'cancelled' | 'expired'
|
||||
const outcome = ref<'success' | 'cancelled' | 'expired' | null>(null)
|
||||
const outcome = ref<PaymentOutcome | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -194,10 +196,19 @@ const countdownDisplay = computed(() => {
|
||||
|
||||
function reopenPopup() {
|
||||
if (props.payUrl) {
|
||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
const win = window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
if (!win || win.closed) {
|
||||
window.location.href = props.payUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setOutcome(next: PaymentOutcome) {
|
||||
if (outcome.value === next) return
|
||||
outcome.value = next
|
||||
emit('settled', next)
|
||||
}
|
||||
|
||||
async function renderQR() {
|
||||
await nextTick()
|
||||
if (!qrCanvas.value || !qrUrl.value) return
|
||||
@@ -214,23 +225,23 @@ async function pollStatus() {
|
||||
if (order.status === 'COMPLETED' || order.status === 'PAID') {
|
||||
cleanup()
|
||||
paidOrder.value = order
|
||||
outcome.value = 'success'
|
||||
setOutcome('success')
|
||||
emit('success')
|
||||
} else if (order.status === 'CANCELLED') {
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
setOutcome('cancelled')
|
||||
} else if (order.status === 'EXPIRED' || order.status === 'FAILED') {
|
||||
cleanup()
|
||||
outcome.value = 'expired'
|
||||
setOutcome('expired')
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
remainingSeconds.value = Math.max(0, seconds)
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; return }
|
||||
if (remainingSeconds.value <= 0) { setOutcome('expired'); return }
|
||||
countdownTimer = setInterval(() => {
|
||||
remainingSeconds.value--
|
||||
if (remainingSeconds.value <= 0) { outcome.value = 'expired'; cleanup() }
|
||||
if (remainingSeconds.value <= 0) { setOutcome('expired'); cleanup() }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
@@ -240,7 +251,7 @@ async function handleCancel() {
|
||||
try {
|
||||
await paymentAPI.cancelOrder(props.orderId)
|
||||
cleanup()
|
||||
outcome.value = 'cancelled'
|
||||
setOutcome('cancelled')
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
|
||||
163
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
Normal file
163
frontend/src/components/payment/__tests__/paymentFlow.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CreateOrderResult, MethodLimit } from '@/types/payment'
|
||||
import {
|
||||
decidePaymentLaunch,
|
||||
getVisibleMethods,
|
||||
readPaymentRecoverySnapshot,
|
||||
type PaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
|
||||
function methodLimit(overrides: Partial<MethodLimit> = {}): MethodLimit {
|
||||
return {
|
||||
daily_limit: 0,
|
||||
daily_used: 0,
|
||||
daily_remaining: 0,
|
||||
single_min: 0,
|
||||
single_max: 0,
|
||||
fee_rate: 0,
|
||||
available: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createOrderResult(overrides: Partial<CreateOrderResult> = {}): CreateOrderResult {
|
||||
return {
|
||||
order_id: 101,
|
||||
amount: 88,
|
||||
pay_amount: 88,
|
||||
fee_rate: 0,
|
||||
expires_at: '2099-01-01T00:10:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getVisibleMethods', () => {
|
||||
it('filters hidden provider methods and normalizes aliases', () => {
|
||||
const visible = getVisibleMethods({
|
||||
alipay_direct: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
stripe: methodLimit({ fee_rate: 3 }),
|
||||
})
|
||||
|
||||
expect(visible).toEqual({
|
||||
alipay: methodLimit({ single_min: 5 }),
|
||||
wxpay: methodLimit({ single_max: 100 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers canonical visible methods over aliases when both exist', () => {
|
||||
const visible = getVisibleMethods({
|
||||
alipay: methodLimit({ single_min: 2 }),
|
||||
alipay_direct: methodLimit({ single_min: 9 }),
|
||||
wxpay_direct: methodLimit({ fee_rate: 1.2 }),
|
||||
})
|
||||
|
||||
expect(visible.alipay.single_min).toBe(2)
|
||||
expect(visible.wxpay.fee_rate).toBe(1.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decidePaymentLaunch', () => {
|
||||
it('uses Stripe popup waiting flow for desktop Alipay client secret', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
client_secret: 'cs_test',
|
||||
resume_token: 'resume-1',
|
||||
}), {
|
||||
visibleMethod: 'alipay',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('stripe_popup')
|
||||
expect(decision.paymentState.paymentType).toBe('alipay')
|
||||
expect(decision.stripeMethod).toBe('alipay')
|
||||
expect(decision.recovery.resumeToken).toBe('resume-1')
|
||||
})
|
||||
|
||||
it('uses Stripe route flow for mobile WeChat client secret', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
client_secret: 'cs_test',
|
||||
}), {
|
||||
visibleMethod: 'wxpay',
|
||||
orderType: 'subscription',
|
||||
isMobile: true,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('stripe_route')
|
||||
expect(decision.stripeMethod).toBe('wechat_pay')
|
||||
expect(decision.paymentState.orderType).toBe('subscription')
|
||||
})
|
||||
|
||||
it('keeps hosted redirect metadata for recovery flows', () => {
|
||||
const decision = decidePaymentLaunch(createOrderResult({
|
||||
pay_url: 'https://pay.example.com/session/abc',
|
||||
payment_mode: 'popup',
|
||||
resume_token: 'resume-2',
|
||||
}), {
|
||||
visibleMethod: 'wxpay',
|
||||
orderType: 'balance',
|
||||
isMobile: false,
|
||||
})
|
||||
|
||||
expect(decision.kind).toBe('redirect_waiting')
|
||||
expect(decision.paymentState.payUrl).toBe('https://pay.example.com/session/abc')
|
||||
expect(decision.recovery.paymentMode).toBe('popup')
|
||||
expect(decision.recovery.resumeToken).toBe('resume-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPaymentRecoverySnapshot', () => {
|
||||
it('restores an unexpired snapshot when the resume token matches', () => {
|
||||
const snapshot: PaymentRecoverySnapshot = {
|
||||
orderId: 33,
|
||||
amount: 18,
|
||||
qrCode: '',
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
paymentType: 'alipay',
|
||||
payUrl: 'https://pay.example.com/session/33',
|
||||
clientSecret: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken: 'resume-33',
|
||||
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
|
||||
}
|
||||
|
||||
const restored = readPaymentRecoverySnapshot(JSON.stringify(snapshot), {
|
||||
now: Date.UTC(2099, 0, 1, 0, 1, 0),
|
||||
resumeToken: 'resume-33',
|
||||
})
|
||||
|
||||
expect(restored?.orderId).toBe(33)
|
||||
})
|
||||
|
||||
it('drops expired or mismatched recovery snapshots', () => {
|
||||
const expiredSnapshot: PaymentRecoverySnapshot = {
|
||||
orderId: 55,
|
||||
amount: 18,
|
||||
qrCode: '',
|
||||
expiresAt: '2024-01-01T00:10:00.000Z',
|
||||
paymentType: 'wxpay',
|
||||
payUrl: 'https://pay.example.com/session/55',
|
||||
clientSecret: '',
|
||||
payAmount: 18,
|
||||
orderType: 'balance',
|
||||
paymentMode: 'popup',
|
||||
resumeToken: 'resume-55',
|
||||
createdAt: Date.UTC(2024, 0, 1, 0, 0, 0),
|
||||
}
|
||||
|
||||
expect(readPaymentRecoverySnapshot(JSON.stringify(expiredSnapshot), {
|
||||
now: Date.UTC(2024, 0, 1, 0, 20, 0),
|
||||
resumeToken: 'resume-55',
|
||||
})).toBeNull()
|
||||
|
||||
expect(readPaymentRecoverySnapshot(JSON.stringify({
|
||||
...expiredSnapshot,
|
||||
expiresAt: '2099-01-01T00:10:00.000Z',
|
||||
}), {
|
||||
now: Date.UTC(2099, 0, 1, 0, 1, 0),
|
||||
resumeToken: 'other-token',
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
197
frontend/src/components/payment/paymentFlow.ts
Normal file
197
frontend/src/components/payment/paymentFlow.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { CreateOrderResult, MethodLimit, OrderType } from '@/types/payment'
|
||||
|
||||
export const PAYMENT_RECOVERY_STORAGE_KEY = 'payment.recovery.current'
|
||||
|
||||
const VISIBLE_METHOD_ALIASES = {
|
||||
alipay: 'alipay',
|
||||
alipay_direct: 'alipay',
|
||||
wxpay: 'wxpay',
|
||||
wxpay_direct: 'wxpay',
|
||||
} as const
|
||||
|
||||
export type VisiblePaymentMethod = 'alipay' | 'wxpay'
|
||||
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
|
||||
export type PaymentLaunchKind =
|
||||
| 'qr_waiting'
|
||||
| 'redirect_waiting'
|
||||
| 'stripe_popup'
|
||||
| 'stripe_route'
|
||||
| 'unhandled'
|
||||
|
||||
export interface PaymentRecoverySnapshot {
|
||||
orderId: number
|
||||
amount: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl: string
|
||||
clientSecret: string
|
||||
payAmount: number
|
||||
orderType: OrderType | ''
|
||||
paymentMode: string
|
||||
resumeToken: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface PaymentLaunchContext {
|
||||
visibleMethod: string
|
||||
orderType: OrderType
|
||||
isMobile: boolean
|
||||
now?: number
|
||||
stripePopupUrl?: string
|
||||
stripeRouteUrl?: string
|
||||
}
|
||||
|
||||
export interface PaymentLaunchDecision {
|
||||
kind: PaymentLaunchKind
|
||||
paymentState: PaymentRecoverySnapshot
|
||||
recovery: PaymentRecoverySnapshot
|
||||
stripeMethod?: StripeVisibleMethod
|
||||
}
|
||||
|
||||
type CreateOrderFlowResult = CreateOrderResult & {
|
||||
resume_token?: string
|
||||
}
|
||||
|
||||
type StorageWriter = Pick<Storage, 'removeItem' | 'setItem'>
|
||||
|
||||
export function normalizeVisibleMethod(method: string): VisiblePaymentMethod | '' {
|
||||
const normalized = VISIBLE_METHOD_ALIASES[method.trim() as keyof typeof VISIBLE_METHOD_ALIASES]
|
||||
return normalized ?? ''
|
||||
}
|
||||
|
||||
export function getVisibleMethods(methods: Record<string, MethodLimit>): Record<string, MethodLimit> {
|
||||
const visible: Record<string, MethodLimit> = {}
|
||||
|
||||
Object.entries(methods).forEach(([type, limit]) => {
|
||||
const normalized = normalizeVisibleMethod(type)
|
||||
if (!normalized) return
|
||||
|
||||
const isCanonical = type === normalized
|
||||
const existing = visible[normalized]
|
||||
if (!existing || isCanonical) {
|
||||
visible[normalized] = { ...limit }
|
||||
}
|
||||
})
|
||||
|
||||
return visible
|
||||
}
|
||||
|
||||
export function decidePaymentLaunch(
|
||||
result: CreateOrderFlowResult,
|
||||
context: PaymentLaunchContext,
|
||||
): PaymentLaunchDecision {
|
||||
const visibleMethod = normalizeVisibleMethod(context.visibleMethod) || context.visibleMethod
|
||||
const baseState = createPaymentRecoverySnapshot({
|
||||
orderId: result.order_id,
|
||||
amount: result.amount,
|
||||
qrCode: result.qr_code || '',
|
||||
expiresAt: result.expires_at || '',
|
||||
paymentType: visibleMethod,
|
||||
payUrl: result.pay_url || '',
|
||||
clientSecret: result.client_secret || '',
|
||||
payAmount: result.pay_amount,
|
||||
orderType: context.orderType,
|
||||
paymentMode: (result.payment_mode || '').trim(),
|
||||
resumeToken: result.resume_token || '',
|
||||
}, context.now)
|
||||
|
||||
if (baseState.clientSecret) {
|
||||
const stripeMethod: StripeVisibleMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
||||
const kind: PaymentLaunchKind = stripeMethod === 'alipay' && !context.isMobile
|
||||
? 'stripe_popup'
|
||||
: 'stripe_route'
|
||||
const payUrl = kind === 'stripe_popup'
|
||||
? context.stripePopupUrl || context.stripeRouteUrl || ''
|
||||
: context.stripeRouteUrl || context.stripePopupUrl || ''
|
||||
const paymentState = { ...baseState, payUrl }
|
||||
return { kind, paymentState, recovery: paymentState, stripeMethod }
|
||||
}
|
||||
|
||||
if (baseState.qrCode) {
|
||||
return { kind: 'qr_waiting', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
if (baseState.payUrl) {
|
||||
return { kind: 'redirect_waiting', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
return { kind: 'unhandled', paymentState: baseState, recovery: baseState }
|
||||
}
|
||||
|
||||
export function createPaymentRecoverySnapshot(
|
||||
state: Omit<PaymentRecoverySnapshot, 'createdAt'>,
|
||||
now = Date.now(),
|
||||
): PaymentRecoverySnapshot {
|
||||
return {
|
||||
...state,
|
||||
createdAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaymentRecoverySnapshot(
|
||||
storage: StorageWriter,
|
||||
snapshot: PaymentRecoverySnapshot,
|
||||
key = PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
): void {
|
||||
storage.setItem(key, JSON.stringify(snapshot))
|
||||
}
|
||||
|
||||
export function clearPaymentRecoverySnapshot(
|
||||
storage: Pick<Storage, 'removeItem'>,
|
||||
key = PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
): void {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
|
||||
export function readPaymentRecoverySnapshot(
|
||||
raw: string | null | undefined,
|
||||
options: { now?: number; resumeToken?: string } = {},
|
||||
): PaymentRecoverySnapshot | null {
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PaymentRecoverySnapshot>
|
||||
if (
|
||||
typeof parsed.orderId !== 'number'
|
||||
|| typeof parsed.amount !== 'number'
|
||||
|| typeof parsed.qrCode !== 'string'
|
||||
|| typeof parsed.expiresAt !== 'string'
|
||||
|| typeof parsed.paymentType !== 'string'
|
||||
|| typeof parsed.payUrl !== 'string'
|
||||
|| typeof parsed.clientSecret !== 'string'
|
||||
|| typeof parsed.payAmount !== 'number'
|
||||
|| typeof parsed.paymentMode !== 'string'
|
||||
|| typeof parsed.resumeToken !== 'string'
|
||||
|| typeof parsed.createdAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = options.now ?? Date.now()
|
||||
const expiresAt = Date.parse(parsed.expiresAt)
|
||||
if (Number.isFinite(expiresAt) && expiresAt <= now) {
|
||||
return null
|
||||
}
|
||||
if (options.resumeToken && parsed.resumeToken && parsed.resumeToken !== options.resumeToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: parsed.orderId,
|
||||
amount: parsed.amount,
|
||||
qrCode: parsed.qrCode,
|
||||
expiresAt: parsed.expiresAt,
|
||||
paymentType: parsed.paymentType,
|
||||
payUrl: parsed.payUrl,
|
||||
clientSecret: parsed.clientSecret,
|
||||
payAmount: parsed.payAmount,
|
||||
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
|
||||
paymentMode: parsed.paymentMode,
|
||||
resumeToken: parsed.resumeToken,
|
||||
createdAt: parsed.createdAt,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
55
frontend/src/router/__tests__/wechat-route.spec.ts
Normal file
55
frontend/src/router/__tests__/wechat-route.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const authStore = vi.hoisted(() => ({
|
||||
checkAuth: vi.fn(),
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
isSimpleMode: false,
|
||||
}))
|
||||
|
||||
const appStore = vi.hoisted(() => ({
|
||||
siteName: 'Sub2API',
|
||||
backendModeEnabled: false,
|
||||
cachedPublicSettings: null as null | Record<string, unknown>,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => authStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => appStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/adminSettings', () => ({
|
||||
useAdminSettingsStore: () => ({
|
||||
customMenuItems: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useNavigationLoading', () => ({
|
||||
useNavigationLoadingState: () => ({
|
||||
startNavigation: vi.fn(),
|
||||
endNavigation: vi.fn(),
|
||||
isLoading: { value: false },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useRoutePrefetch', () => ({
|
||||
useRoutePrefetch: () => ({
|
||||
triggerPrefetch: vi.fn(),
|
||||
cancelPendingPrefetch: vi.fn(),
|
||||
resetPrefetchState: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('router WeChat OAuth route', () => {
|
||||
it('registers the WeChat callback route as a public route', async () => {
|
||||
const { default: router } = await import('@/router')
|
||||
const route = router.getRoutes().find((record) => record.name === 'WeChatOAuthCallback')
|
||||
|
||||
expect(route?.path).toBe('/auth/wechat/callback')
|
||||
expect(route?.meta.requiresAuth).toBe(false)
|
||||
expect(route?.meta.title).toBe('WeChat OAuth Callback')
|
||||
})
|
||||
})
|
||||
@@ -83,6 +83,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'LinuxDo OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/auth/wechat/callback',
|
||||
name: 'WeChatOAuthCallback',
|
||||
component: () => import('@/views/auth/WechatCallbackView.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'WeChat OAuth Callback'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/auth/oidc/callback',
|
||||
name: 'OIDCOAuthCallback',
|
||||
|
||||
@@ -336,6 +336,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
custom_menu_items: [],
|
||||
custom_endpoints: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
wechat_oauth_enabled: false,
|
||||
oidc_oauth_enabled: false,
|
||||
oidc_oauth_provider_name: 'OIDC',
|
||||
backend_mode_enabled: false,
|
||||
|
||||
@@ -123,6 +123,7 @@ export interface PublicSettings {
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
custom_endpoints: CustomEndpoint[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
wechat_oauth_enabled: boolean
|
||||
oidc_oauth_enabled: boolean
|
||||
oidc_oauth_provider_name: string
|
||||
backend_mode_enabled: boolean
|
||||
|
||||
@@ -1586,6 +1586,221 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ localText('认证来源默认值', 'Auth Source Defaults') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'按注册来源配置新用户默认余额、并发、订阅与授权策略。',
|
||||
'Configure per-source default balance, concurrency, subscriptions, and grant rules.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-6 p-6">
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ localText('第三方注册强制补充邮箱', 'Require email on third-party signup') }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'启用后,Linux DO、OIDC、微信注册缺少邮箱时必须先补充邮箱地址。',
|
||||
'When enabled, Linux DO, OIDC, and WeChat signups must provide an email before account creation.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.force_email_on_third_party_signup" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div
|
||||
v-for="authSource in authSourceDefaultsMeta"
|
||||
:key="authSource.source"
|
||||
class="rounded-xl border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{{ authSource.title }}</div>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ authSource.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.defaults.defaultBalance') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].balance"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.defaults.defaultConcurrency') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="authSourceDefaults[authSource.source].concurrency"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input"
|
||||
placeholder="5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ localText('注册即授权', 'Grant on signup') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'来源首次注册成功后立即发放默认权益。',
|
||||
'Grant default entitlements immediately after signup.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="authSourceDefaults[authSource.source].grant_on_signup" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ localText('首次绑定时授权', 'Grant on first bind') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'来源首次绑定到现有账号时发放默认权益。',
|
||||
'Grant default entitlements when the source is first bound to an existing user.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="authSourceDefaults[authSource.source].grant_on_first_bind" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{ localText('默认订阅', 'Default subscriptions') }}
|
||||
</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'仅对当前认证来源生效,未配置时不追加来源专属订阅。',
|
||||
'Applies only to this auth source. Leave empty to skip source-specific subscriptions.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="addAuthSourceDefaultSubscription(authSource.source)"
|
||||
:disabled="subscriptionGroups.length === 0"
|
||||
>
|
||||
{{ t('admin.settings.defaults.addDefaultSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="authSourceDefaults[authSource.source].subscriptions.length === 0"
|
||||
class="rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
|
||||
>
|
||||
{{
|
||||
localText(
|
||||
'当前来源未配置专属默认订阅。',
|
||||
'No source-specific default subscriptions configured.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(item, index) in authSourceDefaults[authSource.source].subscriptions"
|
||||
:key="`${authSource.source}-sub-${index}`"
|
||||
class="grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.defaults.subscriptionGroup') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="item.group_id"
|
||||
class="default-sub-group-select"
|
||||
:options="defaultSubscriptionGroupOptions"
|
||||
:placeholder="t('admin.settings.defaults.subscriptionGroup')"
|
||||
>
|
||||
<template #selected="{ option }">
|
||||
<GroupBadge
|
||||
v-if="option"
|
||||
:name="(option as unknown as DefaultSubscriptionGroupOption).label"
|
||||
:platform="(option as unknown as DefaultSubscriptionGroupOption).platform"
|
||||
:subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate"
|
||||
/>
|
||||
<span v-else class="text-gray-400">
|
||||
{{ t('admin.settings.defaults.subscriptionGroup') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as DefaultSubscriptionGroupOption).label"
|
||||
:platform="(option as unknown as DefaultSubscriptionGroupOption).platform"
|
||||
:subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate"
|
||||
:description="(option as unknown as DefaultSubscriptionGroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.defaults.subscriptionValidityDays') }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="item.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="36500"
|
||||
class="input h-[42px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary w-full text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
@click="removeAuthSourceDefaultSubscription(authSource.source, index)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /Tab: Users -->
|
||||
|
||||
<!-- Tab: Gateway — Claude Code, Scheduling -->
|
||||
@@ -1643,19 +1858,38 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.scheduling.allowUngroupedKey') }}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.scheduling.allowUngroupedKey') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.scheduling.allowUngroupedKeyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input v-model="form.allow_ungrouped_key_scheduling" type="checkbox" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.scheduling.allowUngroupedKeyHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input v-model="form.allow_ungrouped_key_scheduling" type="checkbox" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-between rounded border border-gray-200 px-4 py-3 dark:border-dark-700">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ localText('OpenAI 高级调度器', 'OpenAI advanced scheduler') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'切换 OpenAI 侧新增的高级调度开关,供当前分支实验性调度逻辑使用。',
|
||||
'Toggles the new OpenAI advanced scheduler flag for the experimental routing logic on this branch.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.openai_advanced_scheduler_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2450,6 +2684,59 @@
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="visibleMethod in paymentVisibleMethodCards"
|
||||
:key="visibleMethod.key"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">
|
||||
{{
|
||||
localText(
|
||||
`${visibleMethod.title} 可见方式`,
|
||||
`${visibleMethod.title} visible method`
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'控制前台结算页是否展示该方式,以及展示时使用的来源键。',
|
||||
'Controls whether checkout shows this method and which source key it exposes.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="getPaymentVisibleMethodEnabled(visibleMethod.key)"
|
||||
@update:model-value="setPaymentVisibleMethodEnabled(visibleMethod.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="input-label">
|
||||
{{ localText('来源键', 'Source key') }}
|
||||
</label>
|
||||
<input
|
||||
:value="getPaymentVisibleMethodSource(visibleMethod.key)"
|
||||
@input="setPaymentVisibleMethodSource(visibleMethod.key, ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="visibleMethod.key"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-400">
|
||||
{{
|
||||
localText(
|
||||
'留空表示由后端使用默认来源;可填 easypay、alipay、wxpay 等来源标识。',
|
||||
'Leave blank to let the backend decide. Typical values are easypay, alipay, or wxpay.'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 5: Help image + text -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -2827,7 +3114,14 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api'
|
||||
import {
|
||||
appendAuthSourceDefaultsToUpdateRequest,
|
||||
buildAuthSourceDefaultsState,
|
||||
normalizeDefaultSubscriptionSettings,
|
||||
} from '@/api/admin/settings'
|
||||
import type {
|
||||
AuthSourceDefaultsState,
|
||||
AuthSourceType,
|
||||
SystemSettings,
|
||||
UpdateSettingsRequest,
|
||||
DefaultSubscriptionSetting,
|
||||
@@ -2864,6 +3158,10 @@ const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
function localText(zh: string, en: string): string {
|
||||
return locale.value.startsWith('zh') ? zh : en
|
||||
}
|
||||
|
||||
type SettingsTab = 'general' | 'security' | 'users' | 'gateway' | 'payment' | 'email' | 'backup'
|
||||
const activeTab = ref<SettingsTab>('general')
|
||||
const settingsTabs = [
|
||||
@@ -2960,6 +3258,12 @@ type SettingsForm = SystemSettings & {
|
||||
turnstile_secret_key: string
|
||||
linuxdo_connect_client_secret: string
|
||||
oidc_connect_client_secret: string
|
||||
force_email_on_third_party_signup: boolean
|
||||
payment_visible_method_alipay_source: string
|
||||
payment_visible_method_wxpay_source: string
|
||||
payment_visible_method_alipay_enabled: boolean
|
||||
payment_visible_method_wxpay_enabled: boolean
|
||||
openai_advanced_scheduler_enabled: boolean
|
||||
}
|
||||
|
||||
const form = reactive<SettingsForm>({
|
||||
@@ -2974,6 +3278,7 @@ const form = reactive<SettingsForm>({
|
||||
default_balance: 0,
|
||||
default_concurrency: 1,
|
||||
default_subscriptions: [],
|
||||
force_email_on_third_party_signup: false,
|
||||
site_name: 'Sub2API',
|
||||
site_logo: '',
|
||||
site_subtitle: 'Subscription to API Conversion Platform',
|
||||
@@ -2983,7 +3288,7 @@ const form = reactive<SettingsForm>({
|
||||
home_content: '',
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_recharge_fee_rate: 0, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_recharge_fee_rate: 0, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling', payment_visible_method_alipay_source: '', payment_visible_method_wxpay_source: '', payment_visible_method_alipay_enabled: false, payment_visible_method_wxpay_enabled: false,
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
@@ -3051,6 +3356,7 @@ const form = reactive<SettingsForm>({
|
||||
max_claude_code_version: '',
|
||||
// 分组隔离
|
||||
allow_ungrouped_key_scheduling: false,
|
||||
openai_advanced_scheduler_enabled: false,
|
||||
// Gateway forwarding behavior
|
||||
enable_fingerprint_unification: true,
|
||||
enable_metadata_passthrough: false,
|
||||
@@ -3063,6 +3369,74 @@ const form = reactive<SettingsForm>({
|
||||
account_quota_notify_emails: [] as NotifyEmailEntry[]
|
||||
})
|
||||
|
||||
const authSourceDefaults = reactive<AuthSourceDefaultsState>(buildAuthSourceDefaultsState({}))
|
||||
|
||||
const authSourceDefaultsMeta = computed(() => [
|
||||
{
|
||||
source: 'email' as AuthSourceType,
|
||||
title: localText('邮箱注册', 'Email signup'),
|
||||
description: localText('适用于邮箱密码注册的新用户默认配额。', 'Default quota grants for email-password signups.')
|
||||
},
|
||||
{
|
||||
source: 'linuxdo' as AuthSourceType,
|
||||
title: localText('Linux DO 登录', 'Linux DO signup'),
|
||||
description: localText('适用于 Linux DO 第三方注册的新用户默认配额。', 'Default quota grants for Linux DO signups.')
|
||||
},
|
||||
{
|
||||
source: 'oidc' as AuthSourceType,
|
||||
title: localText('OIDC 登录', 'OIDC signup'),
|
||||
description: localText('适用于 OIDC 第三方注册的新用户默认配额。', 'Default quota grants for OIDC signups.')
|
||||
},
|
||||
{
|
||||
source: 'wechat' as AuthSourceType,
|
||||
title: localText('微信登录', 'WeChat signup'),
|
||||
description: localText('适用于微信第三方注册的新用户默认配额。', 'Default quota grants for WeChat signups.')
|
||||
},
|
||||
])
|
||||
|
||||
const paymentVisibleMethodCards = computed(() => [
|
||||
{
|
||||
key: 'alipay' as const,
|
||||
title: t('payment.methods.alipay'),
|
||||
enabledField: 'payment_visible_method_alipay_enabled' as const,
|
||||
sourceField: 'payment_visible_method_alipay_source' as const,
|
||||
},
|
||||
{
|
||||
key: 'wxpay' as const,
|
||||
title: t('payment.methods.wxpay'),
|
||||
enabledField: 'payment_visible_method_wxpay_enabled' as const,
|
||||
sourceField: 'payment_visible_method_wxpay_source' as const,
|
||||
},
|
||||
])
|
||||
|
||||
function getPaymentVisibleMethodEnabled(method: 'alipay' | 'wxpay'): boolean {
|
||||
return method === 'alipay'
|
||||
? form.payment_visible_method_alipay_enabled
|
||||
: form.payment_visible_method_wxpay_enabled
|
||||
}
|
||||
|
||||
function setPaymentVisibleMethodEnabled(method: 'alipay' | 'wxpay', enabled: boolean) {
|
||||
if (method === 'alipay') {
|
||||
form.payment_visible_method_alipay_enabled = enabled
|
||||
return
|
||||
}
|
||||
form.payment_visible_method_wxpay_enabled = enabled
|
||||
}
|
||||
|
||||
function getPaymentVisibleMethodSource(method: 'alipay' | 'wxpay'): string {
|
||||
return method === 'alipay'
|
||||
? form.payment_visible_method_alipay_source
|
||||
: form.payment_visible_method_wxpay_source
|
||||
}
|
||||
|
||||
function setPaymentVisibleMethodSource(method: 'alipay' | 'wxpay', source: string) {
|
||||
if (method === 'alipay') {
|
||||
form.payment_visible_method_alipay_source = source
|
||||
return
|
||||
}
|
||||
form.payment_visible_method_wxpay_source = source
|
||||
}
|
||||
|
||||
// Proxies for web search emulation ProxySelector
|
||||
const webSearchProxies = ref<Proxy[]>([])
|
||||
|
||||
@@ -3428,15 +3802,9 @@ async function loadSettings() {
|
||||
(form as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(settings))
|
||||
form.backend_mode_enabled = settings.backend_mode_enabled
|
||||
form.default_subscriptions = Array.isArray(settings.default_subscriptions)
|
||||
? settings.default_subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item) => ({
|
||||
group_id: item.group_id,
|
||||
validity_days: item.validity_days
|
||||
}))
|
||||
: []
|
||||
form.default_subscriptions = normalizeDefaultSubscriptionSettings(settings.default_subscriptions)
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
settings.registration_email_suffix_whitelist
|
||||
)
|
||||
@@ -3471,10 +3839,18 @@ async function loadSubscriptionGroups() {
|
||||
}
|
||||
}
|
||||
|
||||
function findNextAvailableSubscriptionGroup(
|
||||
existingGroupIDs: number[]
|
||||
): AdminGroup | undefined {
|
||||
const existing = new Set(existingGroupIDs)
|
||||
return subscriptionGroups.value.find((group) => !existing.has(group.id))
|
||||
}
|
||||
|
||||
function addDefaultSubscription() {
|
||||
if (subscriptionGroups.value.length === 0) return
|
||||
const existing = new Set(form.default_subscriptions.map((item) => item.group_id))
|
||||
const candidate = subscriptionGroups.value.find((group) => !existing.has(group.id))
|
||||
const candidate = findNextAvailableSubscriptionGroup(
|
||||
form.default_subscriptions.map((item) => item.group_id)
|
||||
)
|
||||
if (!candidate) return
|
||||
form.default_subscriptions.push({
|
||||
group_id: candidate.id,
|
||||
@@ -3486,6 +3862,36 @@ function removeDefaultSubscription(index: number) {
|
||||
form.default_subscriptions.splice(index, 1)
|
||||
}
|
||||
|
||||
function addAuthSourceDefaultSubscription(source: AuthSourceType) {
|
||||
if (subscriptionGroups.value.length === 0) return
|
||||
const candidate = findNextAvailableSubscriptionGroup(
|
||||
authSourceDefaults[source].subscriptions.map((item) => item.group_id)
|
||||
)
|
||||
if (!candidate) return
|
||||
authSourceDefaults[source].subscriptions.push({
|
||||
group_id: candidate.id,
|
||||
validity_days: 30
|
||||
})
|
||||
}
|
||||
|
||||
function removeAuthSourceDefaultSubscription(source: AuthSourceType, index: number) {
|
||||
authSourceDefaults[source].subscriptions.splice(index, 1)
|
||||
}
|
||||
|
||||
function findDuplicateDefaultSubscription(
|
||||
subscriptions: DefaultSubscriptionSetting[]
|
||||
): DefaultSubscriptionSetting | undefined {
|
||||
const seenGroupIDs = new Set<number>()
|
||||
|
||||
return subscriptions.find((item) => {
|
||||
if (seenGroupIDs.has(item.group_id)) {
|
||||
return true
|
||||
}
|
||||
seenGroupIDs.add(item.group_id)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -3520,21 +3926,12 @@ async function saveSettings() {
|
||||
form.table_default_page_size = normalizedTableDefaultPageSize
|
||||
form.table_page_size_options = normalizedTablePageSizeOptions
|
||||
|
||||
const normalizedDefaultSubscriptions = form.default_subscriptions
|
||||
.filter((item) => item.group_id > 0 && item.validity_days > 0)
|
||||
.map((item: DefaultSubscriptionSetting) => ({
|
||||
group_id: item.group_id,
|
||||
validity_days: Math.min(36500, Math.max(1, Math.floor(item.validity_days)))
|
||||
}))
|
||||
|
||||
const seenGroupIDs = new Set<number>()
|
||||
const duplicateDefaultSubscription = normalizedDefaultSubscriptions.find((item) => {
|
||||
if (seenGroupIDs.has(item.group_id)) {
|
||||
return true
|
||||
}
|
||||
seenGroupIDs.add(item.group_id)
|
||||
return false
|
||||
})
|
||||
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
|
||||
form.default_subscriptions
|
||||
)
|
||||
const duplicateDefaultSubscription = findDuplicateDefaultSubscription(
|
||||
normalizedDefaultSubscriptions
|
||||
)
|
||||
if (duplicateDefaultSubscription) {
|
||||
appStore.showError(
|
||||
t('admin.settings.defaults.defaultSubscriptionsDuplicate', {
|
||||
@@ -3544,6 +3941,23 @@ async function saveSettings() {
|
||||
return
|
||||
}
|
||||
|
||||
for (const authSource of authSourceDefaultsMeta.value) {
|
||||
authSourceDefaults[authSource.source].subscriptions = normalizeDefaultSubscriptionSettings(
|
||||
authSourceDefaults[authSource.source].subscriptions
|
||||
)
|
||||
const duplicate = findDuplicateDefaultSubscription(
|
||||
authSourceDefaults[authSource.source].subscriptions
|
||||
)
|
||||
if (duplicate) {
|
||||
appStore.showError(
|
||||
`${authSource.title}: ${t('admin.settings.defaults.defaultSubscriptionsDuplicate', {
|
||||
groupId: duplicate.group_id
|
||||
})}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL fields — novalidate disables browser-native checks, so we validate here
|
||||
const isValidHttpUrl = (url: string): boolean => {
|
||||
if (!url) return true
|
||||
@@ -3571,6 +3985,7 @@ async function saveSettings() {
|
||||
default_balance: form.default_balance,
|
||||
default_concurrency: form.default_concurrency,
|
||||
default_subscriptions: normalizedDefaultSubscriptions,
|
||||
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
|
||||
site_name: form.site_name,
|
||||
site_logo: form.site_logo,
|
||||
site_subtitle: form.site_subtitle,
|
||||
@@ -3655,6 +4070,11 @@ async function saveSettings() {
|
||||
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
|
||||
payment_visible_method_alipay_source: form.payment_visible_method_alipay_source,
|
||||
payment_visible_method_wxpay_source: form.payment_visible_method_wxpay_source,
|
||||
payment_visible_method_alipay_enabled: form.payment_visible_method_alipay_enabled,
|
||||
payment_visible_method_wxpay_enabled: form.payment_visible_method_wxpay_enabled,
|
||||
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
|
||||
// Balance & quota notification
|
||||
balance_low_notify_enabled: form.balance_low_notify_enabled,
|
||||
balance_low_notify_threshold: Number(form.balance_low_notify_threshold) || 0,
|
||||
@@ -3663,12 +4083,15 @@ async function saveSettings() {
|
||||
account_quota_notify_emails: (form.account_quota_notify_emails || []).filter((e) => e.email.trim() !== ''),
|
||||
}
|
||||
|
||||
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults)
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
for (const [key, value] of Object.entries(updated)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
(form as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated))
|
||||
registrationEmailSuffixWhitelistTags.value = normalizeRegistrationEmailSuffixDomains(
|
||||
updated.registration_email_suffix_whitelist
|
||||
)
|
||||
|
||||
@@ -11,32 +11,94 @@
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.linuxdo.invitationRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use LinuxDo profile details
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from LinuxDo to this account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
alt="LinuxDo avatar"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.linuxdo.invitationRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{ isSubmitting ? t('auth.linuxdo.completing') : t('auth.linuxdo.completeRegistration') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the LinuxDo profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -71,7 +133,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { completeLinuxDoOAuthRegistration } from '@/api/auth'
|
||||
import {
|
||||
completeLinuxDoOAuthRegistration,
|
||||
exchangePendingOAuthCompletion,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -85,11 +152,16 @@ const errorMessage = ref('')
|
||||
|
||||
// Invitation code flow state
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -106,6 +178,54 @@ function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: {
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.linuxdo.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
@@ -113,8 +233,8 @@ async function handleSubmitInvitation() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeLinuxDoOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
@@ -134,63 +254,65 @@ async function handleSubmitInvitation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.linuxdo.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.linuxdo.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Store refresh token and expires_at (convert to timestamp) if provided
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
@@ -209,4 +331,3 @@ onMounted(async () => {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<div v-if="!backendModeEnabled && (linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled)" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
@@ -200,6 +205,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import TotpLoginModal from '@/components/auth/TotpLoginModal.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
@@ -225,6 +231,7 @@ const showPassword = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
@@ -267,6 +274,7 @@ onMounted(async () => {
|
||||
turnstileEnabled.value = settings.turnstile_enabled
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = settings.wechat_oauth_enabled
|
||||
backendModeEnabled.value = settings.backend_mode_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
|
||||
@@ -15,36 +15,99 @@
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation" class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use {{ providerName }} profile details
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from {{ providerName }} to this
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -81,7 +144,10 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
completeOIDCOAuthRegistration,
|
||||
getPublicSettings
|
||||
exchangePendingOAuthCompletion,
|
||||
getPublicSettings,
|
||||
type OAuthAdoptionDecision,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -95,12 +161,17 @@ const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const needsInvitation = ref(false)
|
||||
const pendingOAuthToken = ref('')
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const providerName = ref('OIDC')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
@@ -129,6 +200,54 @@ async function loadProviderName() {
|
||||
}
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: {
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
@@ -136,8 +255,8 @@ async function handleSubmitInvitation() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeOIDCOAuthRegistration(
|
||||
pendingOAuthToken.value,
|
||||
invitationCode.value.trim()
|
||||
invitationCode.value.trim(),
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
@@ -157,63 +276,67 @@ async function handleSubmitInvitation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision())
|
||||
await finalizeLogin(completion, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
void loadProviderName()
|
||||
|
||||
const params = parseFragmentParams()
|
||||
const token = params.get('access_token') || ''
|
||||
const refreshToken = params.get('refresh_token') || ''
|
||||
const expiresInStr = params.get('expires_in') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
if (error === 'invitation_required') {
|
||||
pendingOAuthToken.value = params.get('pending_oauth_token') || ''
|
||||
redirectTo.value = sanitizeRedirectPath(params.get('redirect'))
|
||||
if (!pendingOAuthToken.value) {
|
||||
errorMessage.value = t('auth.oidc.invalidPendingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
errorMessage.value = t('auth.oidc.callbackMissingToken')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
}
|
||||
if (expiresInStr) {
|
||||
const expiresIn = parseInt(expiresInStr, 10)
|
||||
if (!isNaN(expiresIn)) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + expiresIn * 1000))
|
||||
}
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.setToken(token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string } } }
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('auth.loginFailed')
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="linuxdoOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<div v-if="linuxdoOAuthEnabled || wechatOAuthEnabled || oidcOAuthEnabled" class="space-y-4">
|
||||
<LinuxDoOAuthSection
|
||||
v-if="linuxdoOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<OidcOAuthSection
|
||||
v-if="oidcOAuthEnabled"
|
||||
:disabled="isLoading"
|
||||
@@ -308,6 +313,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import TurnstileWidget from '@/components/TurnstileWidget.vue'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
@@ -343,6 +349,7 @@ const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const siteName = ref<string>('Sub2API')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(false)
|
||||
const oidcOAuthProviderName = ref<string>('OIDC')
|
||||
const registrationEmailSuffixWhitelist = ref<string[]>([])
|
||||
@@ -397,6 +404,7 @@ onMounted(async () => {
|
||||
turnstileSiteKey.value = settings.turnstile_site_key || ''
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled
|
||||
wechatOAuthEnabled.value = settings.wechat_oauth_enabled
|
||||
oidcOAuthEnabled.value = settings.oidc_oauth_enabled
|
||||
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
|
||||
registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
|
||||
|
||||
361
frontend/src/views/auth/WechatCallbackView.vue
Normal file
361
frontend/src/views/auth/WechatCallbackView.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.oidc.callbackTitle', { providerName }) }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
isProcessing
|
||||
? t('auth.oidc.callbackProcessing', { providerName })
|
||||
: t('auth.oidc.callbackHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="needsInvitation || needsAdoptionConfirmation" class="space-y-4">
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Use {{ providerName }} profile details
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
Choose whether to apply the nickname or avatar from {{ providerName }} to this account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use display name
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="`${providerName} avatar`"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
Use avatar
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oidc.invitationRequired', { providerName }) }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<p v-if="invitationError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ invitationError }}
|
||||
</p>
|
||||
</transition>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{
|
||||
isSubmitting
|
||||
? t('auth.oidc.completing')
|
||||
: t('auth.oidc.completeRegistration')
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Review the {{ providerName }} profile details before continuing.
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : 'Continue' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<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>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">
|
||||
{{ t('auth.oidc.backToLogin') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
interface PendingOAuthExchangeResponse {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
redirect?: string
|
||||
error?: string
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
|
||||
const providerName = 'WeChat'
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): Record<string, boolean> {
|
||||
return {
|
||||
adopt_display_name: adoptDisplayName.value,
|
||||
adopt_avatar: adoptAvatar.value,
|
||||
}
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: PendingOAuthExchangeResponse) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: PendingOAuthExchangeResponse): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
async function exchangePendingOAuthCompletion(): Promise<PendingOAuthExchangeResponse> {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>('/auth/oauth/pending/exchange', {})
|
||||
return data
|
||||
}
|
||||
|
||||
async function finalizeLogin(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (!completion.access_token) {
|
||||
throw new Error(t('auth.oidc.callbackMissingToken'))
|
||||
}
|
||||
|
||||
if (completion.refresh_token) {
|
||||
localStorage.setItem('refresh_token', completion.refresh_token)
|
||||
}
|
||||
if (completion.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + completion.expires_in * 1000))
|
||||
}
|
||||
|
||||
await authStore.setToken(completion.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function completeWeChatOAuthRegistration(invitation: string): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: invitation,
|
||||
...currentAdoptionDecision(),
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const tokenData = await completeWeChatOAuthRegistration(invitationCode.value.trim())
|
||||
if (tokenData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', tokenData.refresh_token)
|
||||
}
|
||||
if (tokenData.expires_in) {
|
||||
localStorage.setItem('token_expires_at', String(Date.now() + tokenData.expires_in * 1000))
|
||||
}
|
||||
await authStore.setToken(tokenData.access_token)
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
invitationError.value =
|
||||
err.response?.data?.message || err.message || t('auth.oidc.completeRegistrationFailed')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<PendingOAuthExchangeResponse>(
|
||||
'/auth/oauth/pending/exchange',
|
||||
currentAdoptionDecision()
|
||||
)
|
||||
await finalizeLogin(data, redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
|
||||
if (error) {
|
||||
errorMessage.value = errorDesc || error
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const redirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = redirect
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeLogin(completion, redirect)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
t('auth.loginFailed')
|
||||
appStore.showError(errorMessage.value)
|
||||
isProcessing.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>
|
||||
180
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
Normal file
180
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import LinuxDoCallbackView from '../LinuxDoCallbackView.vue'
|
||||
|
||||
const replace = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeLinuxDoOAuthRegistration = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeLinuxDoOAuthRegistration: (...args: any[]) => completeLinuxDoOAuthRegistration(...args)
|
||||
}))
|
||||
|
||||
describe('LinuxDoCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
setToken.mockReset()
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeLinuxDoOAuthRegistration.mockReset()
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('LinuxDo Nick')
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'LinuxDo Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/linuxdo.png'
|
||||
})
|
||||
completeLinuxDoOAuthRegistration.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(LinuxDoCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('LinuxDo Nick')
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(completeLinuxDoOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
})
|
||||
})
|
||||
191
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
Normal file
191
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import OidcCallbackView from '../OidcCallbackView.vue'
|
||||
|
||||
const replace = vi.fn()
|
||||
const showSuccess = vi.fn()
|
||||
const showError = vi.fn()
|
||||
const setToken = vi.fn()
|
||||
const exchangePendingOAuthCompletion = vi.fn()
|
||||
const completeOIDCOAuthRegistration = vi.fn()
|
||||
const getPublicSettings = vi.fn()
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({
|
||||
query: {}
|
||||
}),
|
||||
useRouter: () => ({
|
||||
replace
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (!params?.providerName) {
|
||||
return key
|
||||
}
|
||||
return `${key}:${params.providerName}`
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess,
|
||||
showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
exchangePendingOAuthCompletion: (...args: any[]) => exchangePendingOAuthCompletion(...args),
|
||||
completeOIDCOAuthRegistration: (...args: any[]) => completeOIDCOAuthRegistration(...args),
|
||||
getPublicSettings: (...args: any[]) => getPublicSettings(...args)
|
||||
}))
|
||||
|
||||
describe('OidcCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
replace.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showError.mockReset()
|
||||
setToken.mockReset()
|
||||
exchangePendingOAuthCompletion.mockReset()
|
||||
completeOIDCOAuthRegistration.mockReset()
|
||||
getPublicSettings.mockReset()
|
||||
getPublicSettings.mockResolvedValue({
|
||||
oidc_oauth_provider_name: 'ExampleID'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
exchangePendingOAuthCompletion
|
||||
.mockResolvedValueOnce({
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OIDC Nick')
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(2)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenNthCalledWith(2, {
|
||||
adoptDisplayName: false,
|
||||
adoptAvatar: true
|
||||
})
|
||||
expect(setToken).toHaveBeenCalledWith('access-token')
|
||||
expect(replace).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
exchangePendingOAuthCompletion.mockResolvedValue({
|
||||
error: 'invitation_required',
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'OIDC Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/oidc.png'
|
||||
})
|
||||
completeOIDCOAuthRegistration.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer'
|
||||
})
|
||||
setToken.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(OidcCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('OIDC Nick')
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledTimes(1)
|
||||
expect(exchangePendingOAuthCompletion).toHaveBeenCalledWith()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
|
||||
await checkboxes[1].setValue(false)
|
||||
await wrapper.find('input[type="text"]').setValue('invite-code')
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(completeOIDCOAuthRegistration).toHaveBeenCalledWith('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: false
|
||||
})
|
||||
})
|
||||
})
|
||||
241
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
Normal file
241
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import WechatCallbackView from '@/views/auth/WechatCallbackView.vue'
|
||||
|
||||
const {
|
||||
postMock,
|
||||
replaceMock,
|
||||
setTokenMock,
|
||||
showSuccessMock,
|
||||
showErrorMock,
|
||||
routeState,
|
||||
} = vi.hoisted(() => ({
|
||||
postMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
setTokenMock: vi.fn(),
|
||||
showSuccessMock: vi.fn(),
|
||||
showErrorMock: vi.fn(),
|
||||
routeState: {
|
||||
query: {} as Record<string, unknown>,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
createI18n: () => ({
|
||||
global: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}),
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (key === 'auth.oidc.callbackTitle') {
|
||||
return `Signing you in with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.callbackProcessing') {
|
||||
return `Completing login with ${params?.providerName ?? ''}`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.invitationRequired') {
|
||||
return `${params?.providerName ?? ''} invitation required`.trim()
|
||||
}
|
||||
if (key === 'auth.oidc.completeRegistration') {
|
||||
return 'Complete registration'
|
||||
}
|
||||
if (key === 'auth.oidc.completing') {
|
||||
return 'Completing'
|
||||
}
|
||||
if (key === 'auth.oidc.backToLogin') {
|
||||
return 'Back to login'
|
||||
}
|
||||
if (key === 'auth.invitationCodePlaceholder') {
|
||||
return 'Invitation code'
|
||||
}
|
||||
if (key === 'auth.loginSuccess') {
|
||||
return 'Login success'
|
||||
}
|
||||
if (key === 'auth.loginFailed') {
|
||||
return 'Login failed'
|
||||
}
|
||||
if (key === 'auth.oidc.callbackHint') {
|
||||
return 'Callback hint'
|
||||
}
|
||||
if (key === 'auth.oidc.callbackMissingToken') {
|
||||
return 'Missing login token'
|
||||
}
|
||||
if (key === 'auth.oidc.completeRegistrationFailed') {
|
||||
return 'Complete registration failed'
|
||||
}
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAuthStore: () => ({
|
||||
setToken: setTokenMock,
|
||||
}),
|
||||
useAppStore: () => ({
|
||||
showSuccess: showSuccessMock,
|
||||
showError: showErrorMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
apiClient: {
|
||||
post: postMock,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('WechatCallbackView', () => {
|
||||
beforeEach(() => {
|
||||
postMock.mockReset()
|
||||
replaceMock.mockReset()
|
||||
setTokenMock.mockReset()
|
||||
showSuccessMock.mockReset()
|
||||
showErrorMock.mockReset()
|
||||
routeState.query = {}
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('does not send adoption decisions during the initial exchange', async () => {
|
||||
postMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
},
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('waits for explicit adoption confirmation before finishing a non-invitation login', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
redirect: '/dashboard',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-access-token',
|
||||
refresh_token: 'wechat-refresh-token',
|
||||
expires_in: 3600,
|
||||
token_type: 'Bearer',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
})
|
||||
setTokenMock.mockResolvedValue({})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat Nick')
|
||||
expect(setTokenMock).not.toHaveBeenCalled()
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[1].setValue(false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(1, '/auth/oauth/pending/exchange', {})
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/pending/exchange', {
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: false,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-access-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/dashboard')
|
||||
expect(localStorage.getItem('refresh_token')).toBe('wechat-refresh-token')
|
||||
})
|
||||
|
||||
it('renders adoption choices for invitation flow and submits the selected values', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
error: 'invitation_required',
|
||||
redirect: '/subscriptions',
|
||||
adoption_required: true,
|
||||
suggested_display_name: 'WeChat Nick',
|
||||
suggested_avatar_url: 'https://cdn.example/wechat.png',
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
access_token: 'wechat-invite-token',
|
||||
refresh_token: 'wechat-invite-refresh',
|
||||
expires_in: 600,
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(WechatCallbackView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AuthLayout: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
transition: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('WeChat Nick')
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes).toHaveLength(2)
|
||||
await checkboxes[0].setValue(false)
|
||||
await wrapper.get('input[type="text"]').setValue(' INVITE-CODE ')
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(postMock).toHaveBeenNthCalledWith(2, '/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'INVITE-CODE',
|
||||
adopt_display_name: false,
|
||||
adopt_avatar: true,
|
||||
})
|
||||
expect(setTokenMock).toHaveBeenCalledWith('wechat-invite-token')
|
||||
expect(replaceMock).toHaveBeenCalledWith('/subscriptions')
|
||||
})
|
||||
})
|
||||
@@ -23,20 +23,7 @@
|
||||
:order-type="paymentState.orderType"
|
||||
@done="onPaymentDone"
|
||||
@success="onPaymentSuccess"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="paymentPhase === 'stripe'">
|
||||
<StripePaymentInline
|
||||
:order-id="paymentState.orderId"
|
||||
:amount="paymentState.amount"
|
||||
:client-secret="paymentState.clientSecret"
|
||||
:order-type="paymentState.orderType || undefined"
|
||||
:publishable-key="checkout.stripe_publishable_key"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
@success="onPaymentSuccess"
|
||||
@done="onStripeDone"
|
||||
@back="resetPayment"
|
||||
@redirect="onStripeRedirect"
|
||||
@settled="onPaymentSettled"
|
||||
/>
|
||||
</template>
|
||||
<!-- Tab content (select phase) -->
|
||||
@@ -265,7 +252,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
@@ -273,20 +260,30 @@ import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse, CreateOrderResult, OrderType } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES, STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
||||
import {
|
||||
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||
clearPaymentRecoverySnapshot,
|
||||
decidePaymentLaunch,
|
||||
getVisibleMethods,
|
||||
normalizeVisibleMethod,
|
||||
readPaymentRecoverySnapshot,
|
||||
type PaymentRecoverySnapshot,
|
||||
writePaymentRecoverySnapshot,
|
||||
} from '@/components/payment/paymentFlow'
|
||||
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const paymentStore = usePaymentStore()
|
||||
const subscriptionStore = useSubscriptionStore()
|
||||
@@ -309,23 +306,41 @@ const selectedMethod = ref('')
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
const previewImage = ref('')
|
||||
|
||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||
const paymentState = ref<{
|
||||
orderId: number
|
||||
amount: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl: string
|
||||
clientSecret: string
|
||||
payAmount: number
|
||||
orderType: OrderType | ''
|
||||
}>({ orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||
const paymentPhase = ref<'select' | 'paying'>('select')
|
||||
|
||||
function emptyPaymentState(): PaymentRecoverySnapshot {
|
||||
return {
|
||||
orderId: 0,
|
||||
amount: 0,
|
||||
qrCode: '',
|
||||
expiresAt: '',
|
||||
paymentType: '',
|
||||
payUrl: '',
|
||||
clientSecret: '',
|
||||
payAmount: 0,
|
||||
orderType: '',
|
||||
paymentMode: '',
|
||||
resumeToken: '',
|
||||
createdAt: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const paymentState = ref<PaymentRecoverySnapshot>(emptyPaymentState())
|
||||
|
||||
function persistRecoverySnapshot(snapshot: PaymentRecoverySnapshot) {
|
||||
if (typeof window === 'undefined' || !snapshot.orderId) return
|
||||
writePaymentRecoverySnapshot(window.localStorage, snapshot, PAYMENT_RECOVERY_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function removeRecoverySnapshot() {
|
||||
if (typeof window === 'undefined') return
|
||||
clearPaymentRecoverySnapshot(window.localStorage, PAYMENT_RECOVERY_STORAGE_KEY)
|
||||
}
|
||||
|
||||
function resetPayment() {
|
||||
paymentPhase.value = 'select'
|
||||
paymentState.value = { orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||
paymentState.value = emptyPaymentState()
|
||||
removeRecoverySnapshot()
|
||||
}
|
||||
|
||||
function onPaymentDone() {
|
||||
@@ -338,24 +353,15 @@ function onPaymentDone() {
|
||||
}
|
||||
|
||||
function onPaymentSuccess() {
|
||||
removeRecoverySnapshot()
|
||||
authStore.refreshUser()
|
||||
if (paymentState.value.orderType === 'subscription') {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeDone() {
|
||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||
resetPayment()
|
||||
selectedPlan.value = null
|
||||
if (wasSubscription) {
|
||||
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function onStripeRedirect(orderId: number, payUrl: string) {
|
||||
paymentState.value = { ...paymentState.value, orderId, payUrl, qrCode: '' }
|
||||
paymentPhase.value = 'paying'
|
||||
function onPaymentSettled() {
|
||||
removeRecoverySnapshot()
|
||||
}
|
||||
|
||||
// All checkout data from single API call
|
||||
@@ -371,7 +377,8 @@ const tabs = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||
const visibleMethods = computed(() => getVisibleMethods(checkout.value.methods))
|
||||
const enabledMethods = computed(() => Object.keys(visibleMethods.value))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
const balanceRechargeMultiplier = computed(() => {
|
||||
const multiplier = checkout.value.balance_recharge_multiplier
|
||||
@@ -389,23 +396,33 @@ const planGridClass = computed(() => {
|
||||
// Check if an amount fits a method's [min, max]. 0 = no limit.
|
||||
function amountFitsMethod(amt: number, methodType: string): boolean {
|
||||
if (amt <= 0) return true
|
||||
const ml = checkout.value.methods[methodType]
|
||||
const ml = visibleMethods.value[methodType]
|
||||
if (!ml) return false
|
||||
if (ml.single_min > 0 && amt < ml.single_min) return false
|
||||
if (ml.single_max > 0 && amt > ml.single_max) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Global range for AmountInput (union of all methods, precomputed by backend)
|
||||
const globalMinAmount = computed(() => checkout.value.global_min)
|
||||
const globalMaxAmount = computed(() => checkout.value.global_max)
|
||||
// Visible methods decide the amount range shown to users.
|
||||
const globalMinAmount = computed(() => {
|
||||
const limits = Object.values(visibleMethods.value)
|
||||
if (limits.length === 0) return 0
|
||||
if (limits.some(limit => limit.single_min <= 0)) return 0
|
||||
return Math.min(...limits.map(limit => limit.single_min))
|
||||
})
|
||||
const globalMaxAmount = computed(() => {
|
||||
const limits = Object.values(visibleMethods.value)
|
||||
if (limits.length === 0) return 0
|
||||
if (limits.some(limit => limit.single_max <= 0)) return 0
|
||||
return Math.max(...limits.map(limit => limit.single_max))
|
||||
})
|
||||
|
||||
// Selected method's limits (for validation and error messages)
|
||||
const selectedLimit = computed(() => checkout.value.methods[selectedMethod.value])
|
||||
const selectedLimit = computed(() => visibleMethods.value[selectedMethod.value])
|
||||
|
||||
const methodOptions = computed<PaymentMethodOption[]>(() =>
|
||||
enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
const ml = visibleMethods.value[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
@@ -451,7 +468,7 @@ const canSubmit = computed(() =>
|
||||
const subMethodOptions = computed<PaymentMethodOption[]>(() => {
|
||||
const planPrice = selectedPlan.value?.price ?? 0
|
||||
return enabledMethods.value.map((type) => {
|
||||
const ml = checkout.value.methods[type]
|
||||
const ml = visibleMethods.value[type]
|
||||
return {
|
||||
type,
|
||||
fee_rate: ml?.fee_rate ?? 0,
|
||||
@@ -551,55 +568,58 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
||||
payment_type: selectedMethod.value,
|
||||
order_type: orderType,
|
||||
plan_id: planId,
|
||||
})
|
||||
const openWindow = (url: string) => {
|
||||
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
||||
}) as CreateOrderResult & { resume_token?: string }
|
||||
const openWindow = (url: string, features = POPUP_WINDOW_FEATURES) => {
|
||||
const win = window.open(url, 'paymentPopup', features)
|
||||
if (!win || win.closed) {
|
||||
window.location.href = url
|
||||
}
|
||||
}
|
||||
if (result.client_secret) {
|
||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'stripe'
|
||||
} else if (isMobileDevice() && result.pay_url) {
|
||||
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
window.location.href = result.pay_url
|
||||
return
|
||||
} else if (result.qr_code) {
|
||||
// QR mode: show QR code inline
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, amount: result.amount, qrCode: result.qr_code,
|
||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else if (result.pay_url) {
|
||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||
openWindow(result.pay_url)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
}
|
||||
paymentPhase.value = 'paying'
|
||||
} else {
|
||||
const visibleMethod = normalizeVisibleMethod(selectedMethod.value) || selectedMethod.value
|
||||
const stripeMethod = visibleMethod === 'wxpay' ? 'wechat_pay' : 'alipay'
|
||||
const stripeRouteUrl = result.client_secret
|
||||
? router.resolve({
|
||||
path: '/payment/stripe',
|
||||
query: {
|
||||
order_id: String(result.order_id),
|
||||
client_secret: result.client_secret,
|
||||
method: stripeMethod,
|
||||
resume_token: result.resume_token || undefined,
|
||||
},
|
||||
}).href
|
||||
: ''
|
||||
const decision = decidePaymentLaunch(result, {
|
||||
visibleMethod,
|
||||
orderType,
|
||||
isMobile: isMobileDevice(),
|
||||
stripePopupUrl: stripeRouteUrl,
|
||||
stripeRouteUrl,
|
||||
})
|
||||
|
||||
if (decision.kind === 'unhandled') {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
return
|
||||
}
|
||||
|
||||
paymentState.value = decision.paymentState
|
||||
paymentPhase.value = 'paying'
|
||||
persistRecoverySnapshot(decision.recovery)
|
||||
|
||||
if (decision.kind === 'stripe_popup') {
|
||||
openWindow(decision.paymentState.payUrl, STRIPE_POPUP_WINDOW_FEATURES)
|
||||
return
|
||||
}
|
||||
if (decision.kind === 'stripe_route') {
|
||||
window.location.href = decision.paymentState.payUrl
|
||||
return
|
||||
}
|
||||
if (decision.kind === 'redirect_waiting' && decision.paymentState.payUrl) {
|
||||
if (isMobileDevice()) {
|
||||
window.location.href = decision.paymentState.payUrl
|
||||
return
|
||||
}
|
||||
openWindow(decision.paymentState.payUrl)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const apiErr = err as Record<string, unknown>
|
||||
@@ -630,6 +650,25 @@ onMounted(async () => {
|
||||
})
|
||||
selectedMethod.value = sorted[0]
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
const routeResumeToken = typeof route.query.resume_token === 'string'
|
||||
? route.query.resume_token
|
||||
: undefined
|
||||
const restored = readPaymentRecoverySnapshot(
|
||||
window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY),
|
||||
{ resumeToken: routeResumeToken },
|
||||
)
|
||||
if (restored) {
|
||||
paymentState.value = restored
|
||||
paymentPhase.value = 'paying'
|
||||
const restoredMethod = normalizeVisibleMethod(restored.paymentType)
|
||||
if (restoredMethod) {
|
||||
selectedMethod.value = restoredMethod
|
||||
}
|
||||
} else {
|
||||
removeRecoverySnapshot()
|
||||
}
|
||||
}
|
||||
if (checkout.value.balance_disabled) {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user