Unverified Commit ddf80f5e authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation

fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
parents 4d0483f5 c048ca80
-- Preserve legacy OIDC behavior for upgraded installs that predate the
-- introduction of secure PKCE/id_token defaults. Fresh installs continue to
-- inherit runtime defaults when these rows are absent.
WITH legacy_oidc_install AS (
SELECT 1
FROM settings
WHERE key IN (
'oidc_connect_enabled',
'oidc_connect_client_id',
'oidc_connect_authorize_url',
'oidc_connect_token_url',
'oidc_connect_issuer_url',
'oidc_connect_userinfo_url',
'oidc_connect_frontend_redirect_url'
)
LIMIT 1
)
INSERT INTO settings (key, value)
SELECT defaults.key, 'false'
FROM legacy_oidc_install
CROSS JOIN (
VALUES
('oidc_connect_use_pkce'),
('oidc_connect_validate_id_token')
) AS defaults(key)
WHERE NOT EXISTS (
SELECT 1
FROM settings existing
WHERE existing.key = defaults.key
)
ON CONFLICT (key) DO NOTHING;
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMigration112UsesIdempotentAddColumn(t *testing.T) {
content, err := FS.ReadFile("112_add_payment_order_provider_key_snapshot.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30)")
require.NotContains(t, sql, "ADD COLUMN provider_key VARCHAR(30);")
}
func TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults(t *testing.T) {
content, err := FS.ReadFile("118_wechat_dual_mode_and_auth_source_defaults.sql")
require.NoError(t, err)
sql := string(content)
require.NotContains(t, sql, "UPDATE settings")
require.NotContains(t, sql, "SET value = 'false'")
require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING"))
require.Contains(t, sql, "THEN ''")
}
func TestAuthIdentityReportTypeWideningRunsBeforeLongReportWritersAndStillReconcilesAt121(t *testing.T) {
preflightContent, err := FS.ReadFile("108a_widen_auth_identity_migration_report_type.sql")
require.NoError(t, err)
preflightSQL := string(preflightContent)
require.Contains(t, preflightSQL, "ALTER TABLE auth_identity_migration_reports")
require.Contains(t, preflightSQL, "ALTER COLUMN report_type TYPE VARCHAR(80)")
content, err := FS.ReadFile("109_auth_identity_compat_backfill.sql")
require.NoError(t, err)
sql := string(content)
require.NotContains(t, sql, "ALTER TABLE auth_identity_migration_reports")
followupContent, err := FS.ReadFile("121_auth_identity_migration_report_type_widen.sql")
require.NoError(t, err)
followupSQL := string(followupContent)
require.Contains(t, followupSQL, "ALTER TABLE auth_identity_migration_reports")
require.Contains(t, followupSQL, "ALTER COLUMN report_type TYPE VARCHAR(80)")
}
func TestMigration119DefersPaymentIndexRolloutToOnlineFollowup(t *testing.T) {
content, err := FS.ReadFile("119_enforce_payment_orders_out_trade_no_unique.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "120_enforce_payment_orders_out_trade_no_unique_notx.sql")
require.Contains(t, sql, "NULL;")
require.NotContains(t, sql, "CREATE UNIQUE INDEX")
require.NotContains(t, sql, "DROP INDEX")
followupContent, err := FS.ReadFile("120_enforce_payment_orders_out_trade_no_unique_notx.sql")
require.NoError(t, err)
followupSQL := string(followupContent)
require.Contains(t, followupSQL, "explicit duplicate out_trade_no precheck")
require.Contains(t, followupSQL, "stale invalid paymentorder_out_trade_no_unique index")
require.Contains(t, followupSQL, "CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique")
require.NotContains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique")
require.Contains(t, followupSQL, "DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no")
require.Contains(t, followupSQL, "WHERE out_trade_no <> ''")
alignmentContent, err := FS.ReadFile("120a_align_payment_orders_out_trade_no_index_name.sql")
require.NoError(t, err)
alignmentSQL := string(alignmentContent)
require.Contains(t, alignmentSQL, "paymentorder_out_trade_no_unique")
require.Contains(t, alignmentSQL, "RENAME TO paymentorder_out_trade_no")
}
func TestMigration110SeedsAuthSourceSignupGrantsDisabledByDefault(t *testing.T) {
content, err := FS.ReadFile("110_pending_auth_and_provider_default_grants.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "('auth_source_default_email_grant_on_signup', 'false')")
require.Contains(t, sql, "('auth_source_default_linuxdo_grant_on_signup', 'false')")
require.Contains(t, sql, "('auth_source_default_oidc_grant_on_signup', 'false')")
require.Contains(t, sql, "('auth_source_default_wechat_grant_on_signup', 'false')")
require.NotContains(t, sql, "('auth_source_default_email_grant_on_signup', 'true')")
}
func TestMigration122ScrubsPendingOAuthCompletionTokensAtRest(t *testing.T) {
content, err := FS.ReadFile("122_pending_auth_completion_token_cleanup.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "UPDATE pending_auth_sessions")
require.Contains(t, sql, "completion_response")
require.Contains(t, sql, "access_token")
require.Contains(t, sql, "refresh_token")
require.Contains(t, sql, "expires_in")
require.Contains(t, sql, "token_type")
}
func TestMigration123BackfillsLegacyAuthSourceGrantDefaultsSafely(t *testing.T) {
content, err := FS.ReadFile("123_fix_legacy_auth_source_grant_on_signup_defaults.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "110_pending_auth_and_provider_default_grants.sql")
require.Contains(t, sql, "schema_migrations")
require.Contains(t, sql, "updated_at")
require.Contains(t, sql, "'_grant_on_signup'")
require.Contains(t, sql, "value = 'false'")
require.Contains(t, sql, "auth_identity_migration_reports")
}
func TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely(t *testing.T) {
content, err := FS.ReadFile("124_backfill_legacy_oidc_security_flags.sql")
require.NoError(t, err)
sql := string(content)
require.Contains(t, sql, "oidc_connect_use_pkce")
require.Contains(t, sql, "oidc_connect_validate_id_token")
require.Contains(t, sql, "ON CONFLICT (key) DO NOTHING")
require.Contains(t, sql, "oidc_connect_enabled")
require.Contains(t, sql, "'false'")
}
......@@ -841,7 +841,7 @@ linuxdo_connect:
frontend_redirect_url: "/auth/linuxdo/callback"
token_auth_method: "client_secret_post" # client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce: false
use_pkce: true
userinfo_email_path: ""
userinfo_id_path: ""
userinfo_username_path: ""
......
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: {
post,
},
}))
import {
bindUserAuthIdentity,
type AdminBindAuthIdentityRequest,
type AdminBoundAuthIdentity,
} from '@/api/admin/users'
type Assert<T extends true> = T
type IsExact<T, U> = (
(<G>() => G extends T ? 1 : 2) extends (<G>() => G extends U ? 1 : 2)
? ((<G>() => G extends U ? 1 : 2) extends (<G>() => G extends T ? 1 : 2) ? true : false)
: false
)
type ExpectedAdminBindAuthIdentityRequest = {
provider_type: string
provider_key: string
provider_subject: string
issuer?: string
metadata?: Record<string, unknown>
channel?: {
channel: string
channel_app_id: string
channel_subject: string
metadata?: Record<string, unknown>
}
}
type ExpectedAdminBoundAuthIdentity = {
user_id: number
provider_type: string
provider_key: string
provider_subject: string
verified_at?: string | null
issuer?: string | null
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
channel?: {
channel: string
channel_app_id: string
channel_subject: string
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
} | null
}
const requestContractExact: Assert<
IsExact<AdminBindAuthIdentityRequest, ExpectedAdminBindAuthIdentityRequest>
> = true
const responseContractExact: Assert<
IsExact<AdminBoundAuthIdentity, ExpectedAdminBoundAuthIdentity>
> = true
describe('admin users api auth identity binding', () => {
beforeEach(() => {
post.mockReset()
})
it('posts the backend-compatible auth identity bind payload and returns the backend response shape', async () => {
const payload: AdminBindAuthIdentityRequest = {
provider_type: 'wechat',
provider_key: 'wechat-main',
provider_subject: 'union-123',
metadata: { source: 'admin-repair' },
channel: {
channel: 'open',
channel_app_id: 'wx-open',
channel_subject: 'openid-123',
metadata: { scene: 'migration' },
},
}
const response: AdminBoundAuthIdentity = {
user_id: 9,
provider_type: 'wechat',
provider_key: 'wechat-main',
provider_subject: 'union-123',
verified_at: '2026-04-22T00:00:00Z',
issuer: null,
metadata: { source: 'admin-repair' },
created_at: '2026-04-22T00:00:00Z',
updated_at: '2026-04-22T00:00:00Z',
channel: {
channel: 'open',
channel_app_id: 'wx-open',
channel_subject: 'openid-123',
metadata: { scene: 'migration' },
created_at: '2026-04-22T00:00:00Z',
updated_at: '2026-04-22T00:00:00Z',
},
}
post.mockResolvedValue({ data: response })
const result = await bindUserAuthIdentity(9, payload)
expect(post).toHaveBeenCalledWith('/admin/users/9/auth-identities', payload)
expect(result).toEqual(response)
})
it('keeps bind auth identity request and response types aligned with the backend contract', () => {
expect(requestContractExact).toBe(true)
expect(responseContractExact).toBe(true)
})
})
......@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
})
it('prepares an oauth bind access token cookie before redirect binding', async () => {
it('requests an HttpOnly oauth bind cookie before redirect binding', async () => {
localStorage.setItem('auth_token', 'access-token-value')
const setCookie = vi.fn()
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => '',
set: setCookie
})
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
prepareOAuthBindAccessTokenCookie()
await prepareOAuthBindAccessTokenCookie()
expect(setCookie).toHaveBeenCalledTimes(1)
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
})
})
......@@ -91,6 +91,22 @@ describe('API Client', () => {
const config = adapter.mock.calls[0][0]
expect(config.params?.timezone).toBeUndefined()
})
it('请求默认带 withCredentials 以支持跨域 cookie', async () => {
const adapter = vi.fn().mockResolvedValue({
status: 200,
data: { code: 0, data: {} },
headers: {},
config: {},
statusText: 'OK',
})
apiClient.defaults.adapter = adapter
await apiClient.post('/auth/oauth/bind-token')
const config = adapter.mock.calls[0][0]
expect(config.withCredentials).toBe(true)
})
})
// --- 响应拦截器 ---
......
......@@ -22,8 +22,12 @@ describe('payment api', () => {
post.mockResolvedValue({ data: {} })
})
it('does not expose anonymous public out_trade_no verification', () => {
expect(Object.prototype.hasOwnProperty.call(paymentAPI, 'verifyOrderPublic')).toBe(false)
it('keeps legacy public out_trade_no verification for upgrade compatibility', async () => {
await paymentAPI.verifyOrderPublic('legacy-order-no')
expect(post).toHaveBeenCalledWith('/payment/public/orders/verify', {
out_trade_no: 'legacy-order-no',
})
})
it('keeps signed public resume-token resolve endpoint', async () => {
......
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('user api oauth binding urls', () => {
beforeEach(() => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/api/v1')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('builds third-party bind urls against the bind start endpoint', async () => {
const { buildOAuthBindingStartURL } = await import('@/api/user')
expect(buildOAuthBindingStartURL('linuxdo', { redirectTo: '/settings/profile' })).toBe(
'https://api.example.com/api/v1/auth/oauth/linuxdo/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user'
)
expect(
buildOAuthBindingStartURL('wechat', {
redirectTo: '/settings/profile',
wechatOAuthSettings: {
wechat_oauth_open_enabled: true,
wechat_oauth_mp_enabled: false,
wechat_oauth_mobile_enabled: false
}
})
).toBe(
'https://api.example.com/api/v1/auth/oauth/wechat/bind/start?redirect=%2Fsettings%2Fprofile&intent=bind_current_user&mode=open'
)
})
})
......@@ -8,26 +8,40 @@ import type { AdminUser, UpdateUserRequest, PaginatedResponse, ApiKey } from '@/
export interface AdminBindAuthIdentityChannelRequest {
channel: string
channel_app_id?: string
channel_app_id: string
channel_subject: string
metadata?: Record<string, unknown>
metadata?: Record<string, unknown> | null
}
export interface AdminBindAuthIdentityRequest {
provider_type: string
provider_key: string
provider_subject: string
issuer?: string
metadata?: Record<string, unknown>
issuer?: string | null
metadata?: Record<string, unknown> | null
channel?: AdminBindAuthIdentityChannelRequest
}
export interface AdminBoundAuthIdentityChannel {
channel: string
channel_app_id: string
channel_subject: string
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
}
export interface AdminBoundAuthIdentity {
identity_id: number
user_id: number
provider_type: string
provider_key: string
provider_subject: string
channel_id?: number | null
verified_at?: string | null
issuer?: string | null
metadata: Record<string, unknown> | null
created_at: string
updated_at: string
channel?: AdminBoundAuthIdentityChannel | null
}
/**
......
......@@ -194,6 +194,7 @@ export interface OAuthTokenResponse {
}
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
auth_result?: string
redirect?: string
error?: string
requires_2fa?: boolean
......@@ -206,7 +207,9 @@ export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenRespons
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {}
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
auth_result?: string
}
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
auth_result?: string
......@@ -278,33 +281,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
}
}
export function prepareOAuthBindAccessTokenCookie(): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
if (!getAuthToken()) {
return
}
const token = getAuthToken()
if (!token) {
return
}
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
const path = resolveOAuthBindCookiePath()
document.cookie =
`oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}`
}
function resolveOAuthBindCookiePath(): string {
const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '')
try {
return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1'
} catch {
if (apiBase.startsWith('/')) {
return apiBase
}
return '/api/v1'
}
await apiClient.post('/auth/oauth/bind-token')
}
/**
......
......@@ -13,6 +13,7 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
......
......@@ -67,6 +67,11 @@ export const paymentAPI = {
return apiClient.post<PaymentOrder>('/payment/orders/verify', { out_trade_no: outTradeNo })
},
/** Legacy-compatible public order lookup by out_trade_no */
verifyOrderPublic(outTradeNo: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken(resumeToken: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/resolve', { resume_token: resumeToken })
......
......@@ -150,13 +150,13 @@ export function buildOAuthBindingStartURL(
params.set('mode', mode)
}
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
return `${normalized}/auth/oauth/${provider}/bind/start?${params.toString()}`
}
export function startOAuthBinding(
export async function startOAuthBinding(
provider: BindableOAuthProvider,
options: BuildOAuthBindingStartURLOptions = {}
): void {
): Promise<void> {
if (typeof window === 'undefined') {
return
}
......@@ -164,7 +164,7 @@ export function startOAuthBinding(
if (!startURL) {
return
}
prepareOAuthBindAccessTokenCookie()
await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
}
......
......@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
describe('AppSidebar header styles', () => {
it('does not clip the version badge dropdown', () => {
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/)
const sidebarBrandBlockMatch = componentSource.match(/\.sidebar-brand\s*\{[\s\S]*?\n\}/)
expect(sidebarHeaderBlockMatch).not.toBeNull()
......
......@@ -73,6 +73,7 @@ describe('decidePaymentLaunch', () => {
expect(decision.paymentState.paymentType).toBe('alipay')
expect(decision.stripeMethod).toBe('alipay')
expect(decision.recovery.resumeToken).toBe('resume-1')
expect(decision.recovery.outTradeNo).toBe('')
})
it('uses Stripe route flow for mobile WeChat client secret', () => {
......@@ -94,6 +95,7 @@ describe('decidePaymentLaunch', () => {
pay_url: 'https://pay.example.com/session/abc',
payment_mode: 'popup',
resume_token: 'resume-2',
out_trade_no: 'sub2_abc',
}), {
visibleMethod: 'wxpay',
orderType: 'balance',
......@@ -103,6 +105,7 @@ describe('decidePaymentLaunch', () => {
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.outTradeNo).toBe('sub2_abc')
expect(decision.recovery.resumeToken).toBe('resume-2')
})
......@@ -225,6 +228,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'alipay',
payUrl: 'https://pay.example.com/session/33',
outTradeNo: 'sub2_33',
clientSecret: '',
payAmount: 18,
orderType: 'balance',
......@@ -249,6 +253,7 @@ describe('readPaymentRecoverySnapshot', () => {
expiresAt: '2024-01-01T00:10:00.000Z',
paymentType: 'wxpay',
payUrl: 'https://pay.example.com/session/55',
outTradeNo: 'sub2_55',
clientSecret: '',
payAmount: 18,
orderType: 'balance',
......@@ -264,10 +269,34 @@ describe('readPaymentRecoverySnapshot', () => {
expect(readPaymentRecoverySnapshot(JSON.stringify({
...expiredSnapshot,
outTradeNo: 'sub2_55',
expiresAt: '2099-01-01T00:10:00.000Z',
}), {
now: Date.UTC(2099, 0, 1, 0, 1, 0),
resumeToken: 'other-token',
})).toBeNull()
})
it('keeps backward compatibility with snapshots written before outTradeNo existed', () => {
const restored = readPaymentRecoverySnapshot(JSON.stringify({
orderId: 44,
amount: 18,
qrCode: '',
expiresAt: '2099-01-01T00:10:00.000Z',
paymentType: 'alipay',
payUrl: 'https://pay.example.com/session/44',
clientSecret: '',
payAmount: 18,
orderType: 'balance',
paymentMode: 'popup',
resumeToken: 'resume-44',
createdAt: Date.UTC(2099, 0, 1, 0, 0, 0),
}), {
now: Date.UTC(2099, 0, 1, 0, 1, 0),
resumeToken: 'resume-44',
})
expect(restored?.orderId).toBe(44)
expect(restored?.outTradeNo).toBe('')
})
})
......@@ -34,6 +34,7 @@ export interface PaymentRecoverySnapshot {
expiresAt: string
paymentType: string
payUrl: string
outTradeNo: string
clientSecret: string
payAmount: number
orderType: OrderType | ''
......@@ -132,6 +133,7 @@ export function decidePaymentLaunch(
expiresAt: result.expires_at || '',
paymentType: visibleMethod,
payUrl: result.pay_url || '',
outTradeNo: result.out_trade_no || '',
clientSecret: result.client_secret || '',
payAmount: result.pay_amount,
orderType: context.orderType,
......@@ -227,6 +229,7 @@ export function readPaymentRecoverySnapshot(
|| typeof parsed.expiresAt !== 'string'
|| typeof parsed.paymentType !== 'string'
|| typeof parsed.payUrl !== 'string'
|| (parsed.outTradeNo != null && typeof parsed.outTradeNo !== 'string')
|| typeof parsed.clientSecret !== 'string'
|| typeof parsed.payAmount !== 'number'
|| typeof parsed.paymentMode !== 'string'
......@@ -241,7 +244,7 @@ export function readPaymentRecoverySnapshot(
if (Number.isFinite(expiresAt) && expiresAt <= now) {
return null
}
if (options.resumeToken && parsed.resumeToken && parsed.resumeToken !== options.resumeToken) {
if (options.resumeToken && parsed.resumeToken !== options.resumeToken) {
return null
}
......@@ -252,6 +255,7 @@ export function readPaymentRecoverySnapshot(
expiresAt: parsed.expiresAt,
paymentType: parsed.paymentType,
payUrl: parsed.payUrl,
outTradeNo: parsed.outTradeNo || '',
clientSecret: parsed.clientSecret,
payAmount: parsed.payAmount,
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
......
......@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
: t('profile.authBindings.confirmEmailBindAction')
)
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) {
return appStore.cachedPublicSettings
function resolveLegacyCompatibleWeChatSettings(
settings: WeChatOAuthPublicSettings | null | undefined
): (WeChatOAuthPublicSettings & {
wechat_oauth_open_enabled: boolean
wechat_oauth_mp_enabled: boolean
}) | null {
if (!settings) {
return null
}
if (hasExplicitWeChatOAuthCapabilities(settings)) {
return settings
}
if (typeof settings.wechat_oauth_enabled !== 'boolean') {
return null
}
if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') {
return {
wechat_oauth_enabled: props.wechatEnabled,
wechat_oauth_open_enabled: props.wechatOpenEnabled,
wechat_oauth_mp_enabled: props.wechatMpEnabled,
...settings,
wechat_oauth_open_enabled: settings.wechat_oauth_enabled,
wechat_oauth_mp_enabled: settings.wechat_oauth_enabled,
}
}
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => {
const cachedSettings = resolveLegacyCompatibleWeChatSettings(appStore.cachedPublicSettings)
if (cachedSettings) {
return cachedSettings
}
return null
return resolveLegacyCompatibleWeChatSettings({
wechat_oauth_enabled: props.wechatEnabled,
wechat_oauth_open_enabled: props.wechatOpenEnabled,
wechat_oauth_mp_enabled: props.wechatMpEnabled,
})
})
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value))
......@@ -362,6 +384,27 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
return binding
}
function getDisplayableEmail(user: User | null | undefined): string {
const email = user?.email?.trim() || ''
if (!email) {
return ''
}
if (email.endsWith('.invalid') && !getBindingStatusForUser(user, 'email')) {
return ''
}
return email
}
function isProviderEnabledForBinding(provider: BindableProvider): boolean {
if (provider === 'linuxdo') {
return props.linuxdoEnabled
}
if (provider === 'oidc') {
return props.oidcEnabled
}
return resolvedWeChatBinding.value.mode !== null
}
const providerItems = computed(() => [
{
provider: 'email' as const,
......@@ -375,7 +418,10 @@ const providerItems = computed(() => [
provider: 'linuxdo' as const,
label: t('profile.authBindings.providers.linuxdo'),
bound: getBindingStatus('linuxdo'),
canBind: getBindingDetails('linuxdo')?.can_bind ?? (props.linuxdoEnabled && !getBindingStatus('linuxdo')),
canBind:
!getBindingStatus('linuxdo') &&
isProviderEnabledForBinding('linuxdo') &&
(getBindingDetails('linuxdo')?.can_bind ?? true),
canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind),
details: getBindingDetails('linuxdo'),
},
......@@ -383,7 +429,10 @@ const providerItems = computed(() => [
provider: 'oidc' as const,
label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
bound: getBindingStatus('oidc'),
canBind: getBindingDetails('oidc')?.can_bind ?? (props.oidcEnabled && !getBindingStatus('oidc')),
canBind:
!getBindingStatus('oidc') &&
isProviderEnabledForBinding('oidc') &&
(getBindingDetails('oidc')?.can_bind ?? true),
canUnbind: Boolean(getBindingStatus('oidc') && getBindingDetails('oidc')?.can_unbind),
details: getBindingDetails('oidc'),
},
......@@ -391,7 +440,10 @@ const providerItems = computed(() => [
provider: 'wechat' as const,
label: t('profile.authBindings.providers.wechat'),
bound: getBindingStatus('wechat'),
canBind: getBindingDetails('wechat')?.can_bind ?? (resolvedWeChatBinding.value.mode !== null && !getBindingStatus('wechat')),
canBind:
!getBindingStatus('wechat') &&
isProviderEnabledForBinding('wechat') &&
(getBindingDetails('wechat')?.can_bind ?? true),
canUnbind: Boolean(getBindingStatus('wechat') && getBindingDetails('wechat')?.can_unbind),
details: getBindingDetails('wechat'),
},
......@@ -425,7 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
function providerSummary(provider: UserAuthProvider): string {
if (provider === 'email') {
return currentUser.value?.email || ''
return getDisplayableEmail(currentUser.value)
}
return ''
}
......
......@@ -40,7 +40,7 @@
<div class="space-y-1">
<p class="truncate text-sm text-gray-600 dark:text-gray-300">
{{ user?.email }}
{{ primaryEmailDisplay }}
</p>
<div
v-if="sourceHints.length"
......@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.vue'
import type { User, UserAuthProvider, UserProfileSourceContext } from '@/types'
import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceContext } from '@/types'
const props = withDefaults(defineProps<{
user: User | null
......@@ -206,8 +206,41 @@ const props = withDefaults(defineProps<{
const { t } = useI18n()
function normalizeBindingStatus(binding: boolean | UserAuthBindingStatus | undefined): boolean | null {
if (typeof binding === 'boolean') {
return binding
}
if (!binding) {
return null
}
if (typeof binding.bound === 'boolean') {
return binding.bound
}
return Boolean(binding.provider_subject || binding.issuer || binding.provider_key)
}
function isEmailBound(user: User | null | undefined): boolean {
if (typeof user?.email_bound === 'boolean') {
return user.email_bound
}
const nested = user?.auth_bindings?.email ?? user?.identity_bindings?.email
const normalized = normalizeBindingStatus(nested)
return normalized ?? false
}
const avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
const primaryEmailDisplay = computed(() => {
const email = props.user?.email?.trim() || ''
if (!email) {
return ''
}
if (email.endsWith('.invalid') && !isEmailBound(props.user)) {
return ''
}
return email
})
const avatarInitial = computed(() => displayName.value.charAt(0).toUpperCase() || 'U')
const memberSinceLabel = computed(() => {
const raw = props.user?.created_at?.trim()
......@@ -229,7 +262,7 @@ const memberSinceLabel = computed(() => {
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
email: t('profile.authBindings.providers.email'),
linuxdo: t('profile.authBindings.providers.linuxdo'),
oidc: t('profile.authBindings.providers.oidc', { providerName: 'OIDC' }),
oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }),
wechat: t('profile.authBindings.providers.wechat')
}))
......
......@@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false)
})
it('hides the WeChat bind action when only the legacy aggregate setting is present', () => {
it('keeps the WeChat bind action visible when only the legacy aggregate setting is present', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
......@@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => {
},
})
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="profile-binding-wechat-action"]').exists()).toBe(true)
})
it('starts the WeChat bind flow when only the legacy aggregate setting is present', async () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser(),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: true,
},
})
await wrapper.get('[data-testid="profile-binding-wechat-action"]').trigger('click')
expect(locationState.current.href).toContain('/api/v1/auth/oauth/wechat/start?')
expect(locationState.current.href).toContain('mode=open')
expect(locationState.current.href).toContain('intent=bind_current_user')
expect(locationState.current.href).toContain('redirect=%2Fprofile')
})
it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => {
......@@ -335,6 +356,51 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.get('[data-testid="profile-binding-email-input"]').exists()).toBe(true)
})
it('does not show a synthetic oauth-only email as the bound email summary', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser({
email: 'legacy-user@linuxdo-connect.invalid',
email_bound: false,
auth_bindings: {
email: { bound: false },
},
}),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.text()).not.toContain('legacy-user@linuxdo-connect.invalid')
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
})
it('does not show a synthetic oauth-only email when only fallback auth bindings mark email as unbound', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser({
email: 'legacy-user@wechat-connect.invalid',
auth_bindings: {
email: { bound: false },
},
}),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid')
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound')
})
it('keeps the email form available for replacing a bound primary email', async () => {
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
userApiMocks.bindEmailIdentity.mockResolvedValue(
......@@ -474,4 +540,26 @@ describe('ProfileIdentityBindingsSection', () => {
expect(userApiMocks.unbindAuthIdentity).toHaveBeenCalledWith('linuxdo')
expect(wrapper.get('[data-testid="profile-binding-linuxdo-status"]').text()).toBe('Not bound')
})
it('hides bind actions when provider details say bindable but the provider is disabled', () => {
const wrapper = mount(ProfileIdentityBindingsSection, {
global: {
plugins: [pinia],
},
props: {
user: createUser({
auth_bindings: {
linuxdo: { bound: false, can_bind: true },
oidc: { bound: false, can_bind: true },
},
}),
linuxdoEnabled: false,
oidcEnabled: false,
wechatEnabled: false,
},
})
expect(wrapper.find('[data-testid="profile-binding-linuxdo-action"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="profile-binding-oidc-action"]').exists()).toBe(false)
})
})
......@@ -111,6 +111,67 @@ describe('ProfileInfoCard', () => {
expect(wrapper.text()).toContain('Username synced from LinuxDo')
})
it('uses the configured OIDC provider name in source hints', () => {
const wrapper = mount(ProfileInfoCard, {
props: {
user: createUser({
profile_sources: {
username: { provider: 'oidc', source: 'oidc' }
}
}),
oidcProviderName: 'ExampleID'
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).toContain('Username synced from ExampleID')
})
it('does not display synthetic oauth-only emails as a real bound email', () => {
const wrapper = mount(ProfileInfoCard, {
props: {
user: createUser({
email: 'legacy-user@oidc-connect.invalid',
email_bound: false,
auth_bindings: {
email: { bound: false }
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid')
})
it('does not display synthetic oauth-only emails when only legacy identity bindings mark email as unbound', () => {
const wrapper = mount(ProfileInfoCard, {
props: {
user: createUser({
email: 'legacy-user@wechat-connect.invalid',
identity_bindings: {
email: { bound: false }
}
})
},
global: {
stubs: {
Icon: true
}
}
})
expect(wrapper.text()).not.toContain('legacy-user@wechat-connect.invalid')
})
it('renders the approved overview hero and two-column content shell', () => {
const wrapper = mount(ProfileInfoCard, {
props: {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment