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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user