feat: rebuild auth identity foundation flow

This commit is contained in:
IanShaw027
2026-04-20 17:39:57 +08:00
parent fbd0a2e3c4
commit e9de839d87
123 changed files with 33599 additions and 772 deletions

View 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
})
})
})

View 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,
})
})
})

View File

@@ -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

View File

@@ -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
}