Commit ca4e38aa authored by IanShaw027's avatar IanShaw027
Browse files

fix(profile): stabilize binding compatibility and frontend checks

parent 1aab084e
...@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() => ...@@ -299,20 +299,42 @@ const emailSubmitActionLabel = computed(() =>
: t('profile.authBindings.confirmEmailBindAction') : t('profile.authBindings.confirmEmailBindAction')
) )
const wechatOAuthSettings = computed<WeChatOAuthPublicSettings | null>(() => { function resolveLegacyCompatibleWeChatSettings(
if (hasExplicitWeChatOAuthCapabilities(appStore.cachedPublicSettings)) { settings: WeChatOAuthPublicSettings | null | undefined
return appStore.cachedPublicSettings ): (WeChatOAuthPublicSettings & {
wechat_oauth_open_enabled: boolean
wechat_oauth_mp_enabled: boolean
}) | null {
if (!settings) {
return null
} }
if (typeof props.wechatOpenEnabled === 'boolean' && typeof props.wechatMpEnabled === 'boolean') { if (hasExplicitWeChatOAuthCapabilities(settings)) {
return { return settings
wechat_oauth_enabled: props.wechatEnabled, }
wechat_oauth_open_enabled: props.wechatOpenEnabled,
wechat_oauth_mp_enabled: props.wechatMpEnabled, if (typeof settings.wechat_oauth_enabled !== 'boolean') {
} return null
} }
return null return {
...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 resolveLegacyCompatibleWeChatSettings({
wechat_oauth_enabled: props.wechatEnabled,
wechat_oauth_open_enabled: props.wechatOpenEnabled,
wechat_oauth_mp_enabled: props.wechatMpEnabled,
})
}) })
const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value)) const resolvedWeChatBinding = computed(() => resolveWeChatOAuthStartStrict(wechatOAuthSettings.value))
...@@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus | ...@@ -362,6 +384,17 @@ function getBindingDetails(provider: UserAuthProvider): UserAuthBindingStatus |
return binding 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 { function isProviderEnabledForBinding(provider: BindableProvider): boolean {
if (provider === 'linuxdo') { if (provider === 'linuxdo') {
return props.linuxdoEnabled return props.linuxdoEnabled
...@@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string { ...@@ -444,14 +477,7 @@ function providerIconClass(provider: UserAuthProvider): string {
function providerSummary(provider: UserAuthProvider): string { function providerSummary(provider: UserAuthProvider): string {
if (provider === 'email') { if (provider === 'email') {
const email = currentUser.value?.email?.trim() || '' return getDisplayableEmail(currentUser.value)
if (!email) {
return ''
}
if (currentUser.value?.email_bound === false && email.endsWith('.invalid')) {
return ''
}
return email
} }
return '' return ''
} }
......
...@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -185,7 +185,7 @@ import Icon from '@/components/icons/Icon.vue'
import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue' import ProfileAvatarCard from '@/components/user/profile/ProfileAvatarCard.vue'
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue' import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
import ProfileIdentityBindingsSection from '@/components/user/profile/ProfileIdentityBindingsSection.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<{ const props = withDefaults(defineProps<{
user: User | null user: User | null
...@@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{ ...@@ -206,6 +206,29 @@ const props = withDefaults(defineProps<{
const { t } = useI18n() 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 avatarUrl = computed(() => props.user?.avatar_url?.trim() || '')
const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user')) const displayName = computed(() => props.user?.username?.trim() || props.user?.email?.trim() || t('profile.user'))
const primaryEmailDisplay = computed(() => { const primaryEmailDisplay = computed(() => {
...@@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => { ...@@ -213,7 +236,7 @@ const primaryEmailDisplay = computed(() => {
if (!email) { if (!email) {
return '' return ''
} }
if (props.user?.email_bound === false && email.endsWith('.invalid')) { if (email.endsWith('.invalid') && !isEmailBound(props.user)) {
return '' return ''
} }
return email return email
......
...@@ -188,7 +188,7 @@ describe('ProfileIdentityBindingsSection', () => { ...@@ -188,7 +188,7 @@ 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(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, { const wrapper = mount(ProfileIdentityBindingsSection, {
global: { global: {
plugins: [pinia], plugins: [pinia],
...@@ -201,7 +201,28 @@ describe('ProfileIdentityBindingsSection', () => { ...@@ -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', () => { it('uses explicit cached WeChat capabilities and ignores legacy prop fallbacks', () => {
...@@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => { ...@@ -358,6 +379,28 @@ describe('ProfileIdentityBindingsSection', () => {
expect(wrapper.get('[data-testid="profile-binding-email-status"]').text()).toBe('Not bound') 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 () => { it('keeps the email form available for replacing a bound primary email', async () => {
userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined) userApiMocks.sendEmailBindingCode.mockResolvedValue(undefined)
userApiMocks.bindEmailIdentity.mockResolvedValue( userApiMocks.bindEmailIdentity.mockResolvedValue(
......
...@@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => { ...@@ -152,6 +152,26 @@ describe('ProfileInfoCard', () => {
expect(wrapper.text()).not.toContain('legacy-user@oidc-connect.invalid') 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', () => { it('renders the approved overview hero and two-column content shell', () => {
const wrapper = mount(ProfileInfoCard, { const wrapper = mount(ProfileInfoCard, {
props: { props: {
......
...@@ -3763,11 +3763,7 @@ ...@@ -3763,11 +3763,7 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.payment.description") }} {{ t("admin.settings.payment.description") }}
<a <a
:href=" :href="paymentGuideHref"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'
"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300" class="ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
...@@ -4140,11 +4136,7 @@ ...@@ -4140,11 +4136,7 @@
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500"> <p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.payment.enabledPaymentTypesHint") }} {{ t("admin.settings.payment.enabledPaymentTypesHint") }}
<a <a
:href=" :href="paymentGuideHref"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'
"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300" class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
...@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string { ...@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
return locale.value.startsWith("zh") ? zh : en; return locale.value.startsWith("zh") ? zh : en;
} }
const paymentGuideHref = computed(() =>
locale.value.startsWith("zh")
? "https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98"
: "https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment",
);
type SettingsTab = type SettingsTab =
| "general" | "general"
| "security" | "security"
......
...@@ -46,6 +46,8 @@ const { ...@@ -46,6 +46,8 @@ const {
showSuccess: vi.fn(), showSuccess: vi.fn(),
})); }));
const localeRef = vi.hoisted(() => ({ value: "zh-CN" }));
vi.mock("@/api", () => ({ vi.mock("@/api", () => ({
adminAPI: { adminAPI: {
settings: { settings: {
...@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => { ...@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
"admin.settings.paymentVisibleMethods.sourceLabel": "支付来源", "admin.settings.paymentVisibleMethods.sourceLabel": "支付来源",
"admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。", "admin.settings.paymentVisibleMethods.sourceHint": "启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。",
"admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。", "admin.settings.paymentVisibleMethods.sourceRequiredError": "{title} 已启用,请先选择支付来源。",
"admin.settings.payment.configGuide": "查看支付配置说明",
"admin.settings.payment.findProvider": "查看支持的支付方式",
"admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略", "admin.settings.openaiExperimentalScheduler.title": "OpenAI 实验调度策略",
"admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。", "admin.settings.openaiExperimentalScheduler.description": "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。",
}; };
...@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => { ...@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
useI18n: () => ({ useI18n: () => ({
t: (key: string, params?: Record<string, string>) => t: (key: string, params?: Record<string, string>) =>
(translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`), (translations[key] ?? key).replace(/\{(\w+)\}/g, (_, token) => params?.[token] ?? `{${token}}`),
locale: ref("zh-CN"), locale: localeRef,
}), }),
}; };
}); });
...@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => { ...@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch.mockReset(); adminSettingsFetch.mockReset();
showError.mockReset(); showError.mockReset();
showSuccess.mockReset(); showSuccess.mockReset();
localeRef.value = "zh-CN";
getSettings.mockResolvedValue({ ...baseSettingsResponse }); getSettings.mockResolvedValue({ ...baseSettingsResponse });
updateSettings.mockImplementation(async (payload) => ({ updateSettings.mockImplementation(async (payload) => ({
...@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => { ...@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源"); expect(wrapper.text()).not.toContain("支付来源");
}); });
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
const paymentLinks = wrapper
.findAll("a")
.filter((node) =>
["查看支付配置说明", "查看支持的支付方式"].includes(node.text()),
);
expect(paymentLinks).toHaveLength(2);
expect(paymentLinks[0]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
);
expect(paymentLinks[1]?.attributes("href")).toBe(
"https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98",
);
for (const link of paymentLinks) {
expect(link.attributes("href")).not.toContain("docs/PAYMENT");
}
});
it("does not submit legacy visible payment method settings", async () => { it("does not submit legacy visible payment method settings", async () => {
const wrapper = mountView(); const wrapper = mountView();
......
...@@ -456,7 +456,14 @@ function resolvePendingAccountAction( ...@@ -456,7 +456,14 @@ function resolvePendingAccountAction(
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') { if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
return 'create_account' return 'create_account'
} }
if (raw === 'bind_login_required' || raw === 'bind_login') { if (
raw === 'bind_login_required' ||
raw === 'bind_login' ||
raw === 'existing_account' ||
raw === 'existing_account_required' ||
raw === 'existing_account_binding_required' ||
raw === 'adopt_existing_user_by_email'
) {
return 'bind_login' return 'bind_login'
} }
return 'none' return 'none'
......
...@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() { ...@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
return return
} }
await prepareOAuthBindAccessTokenCookie() try {
window.location.href = startURL await prepareOAuthBindAccessTokenCookie()
window.location.href = startURL
} catch (e: unknown) {
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
}
} }
async function handleExistingAccountBinding() { async function handleExistingAccountBinding() {
......
...@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => { ...@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
) )
}) })
it('keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({
error: 'adopt_existing_user_by_email',
redirect: '/profile/security',
email: 'existing@example.com'
})
const wrapper = mount(LinuxDoCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false
}
}
})
await flushPromises()
expect(showSuccess).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect((wrapper.get('[data-testid="linuxdo-bind-login-email"]').element as HTMLInputElement).value).toBe(
'existing@example.com'
)
})
it('persists a pending auth session when the oauth flow still needs account creation', async () => { it('persists a pending auth session when the oauth flow still needs account creation', async () => {
exchangePendingOAuthCompletion.mockResolvedValue({ exchangePendingOAuthCompletion.mockResolvedValue({
error: 'email_required', error: 'email_required',
......
...@@ -621,6 +621,34 @@ describe('WechatCallbackView', () => { ...@@ -621,6 +621,34 @@ describe('WechatCallbackView', () => {
expect(locationState.current.href).toContain('mode=open') expect(locationState.current.href).toContain('mode=open')
}) })
it('shows an error and stays on the page when preparing bind-token for the current account fails', async () => {
exchangePendingOAuthCompletionMock.mockResolvedValue({
error: 'invitation_required',
redirect: '/usage',
})
getAuthTokenMock.mockReturnValue('current-auth-token')
prepareOAuthBindAccessTokenCookieMock.mockRejectedValue(new Error('bind token failed'))
const wrapper = mount(WechatCallbackView, {
global: {
stubs: {
AuthLayout: { template: '<div><slot /></div>' },
Icon: true,
RouterLink: { template: '<a><slot /></a>' },
transition: false,
},
},
})
await flushPromises()
await wrapper.get('[data-testid="existing-account-submit"]').trigger('click').catch(() => undefined)
await flushPromises()
expect(showErrorMock).toHaveBeenCalledWith('bind token failed')
expect(locationState.current.href).toBe('http://localhost/auth/wechat/callback')
})
it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => { it('collects email, password, and verify code for pending oauth account creation and submits adoption decisions', async () => {
getPublicSettingsMock.mockResolvedValue({ getPublicSettingsMock.mockResolvedValue({
invitation_code_enabled: true, invitation_code_enabled: true,
......
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