fix: defer mini program phone auth until login

This commit is contained in:
kdletters
2026-05-26 19:59:14 +08:00
parent b388b124da
commit 296a7fced9
10 changed files with 520 additions and 19 deletions

View File

@@ -26,6 +26,8 @@ const authMocks = vi.hoisted(() => ({
getAuthAuditLogs: vi.fn(),
getAuthRiskBlocks: vi.fn(),
getAuthSessions: vi.fn(),
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
requestWechatMiniProgramPhoneLogin: vi.fn(),
revokeAuthSessions: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
@@ -52,10 +54,12 @@ vi.mock('../../services/authService', () => ({
getCurrentAuthUser: authMocks.getCurrentAuthUser,
getAuthSessions: authMocks.getAuthSessions,
getCaptchaChallengeFromError: vi.fn(() => null),
isWechatMiniProgramWebViewRuntime: authMocks.isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock: vi.fn(),
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
logoutAuthUser: authMocks.logoutAuthUser,
requestWechatMiniProgramPhoneLogin: authMocks.requestWechatMiniProgramPhoneLogin,
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
resetPassword: authMocks.resetPassword,
revokeAuthSessions: authMocks.revokeAuthSessions,
@@ -152,6 +156,8 @@ beforeEach(() => {
expiresInSeconds: 300,
});
authMocks.startWechatLogin.mockResolvedValue(undefined);
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
});
async function acceptLegalConsent(
@@ -412,6 +418,29 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
const user = userEvent.setup();
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
await waitFor(() => {
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(1);
});
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
});
test('login modal requires first-time legal consent before sms login', async () => {
const user = userEvent.setup();

View File

@@ -32,11 +32,13 @@ import {
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
isWechatMiniProgramWebViewRuntime,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
resetPassword,
revokeAuthSessions,
sendPhoneLoginCode,
@@ -276,6 +278,22 @@ export function AuthGate({ children }: AuthGateProps) {
setInitialSettingsSection(null);
}, []);
const requestMiniProgramLogin = useCallback(() => {
setWechatLoading(true);
setError('');
void requestWechatMiniProgramPhoneLogin()
.catch((miniProgramError) => {
setError(
miniProgramError instanceof Error
? miniProgramError.message
: '请在微信小程序内完成登录。',
);
})
.finally(() => {
setWechatLoading(false);
});
}, []);
const openLoginModal = useCallback(
(postLoginAction?: (() => void) | null) => {
if (readyUser) {
@@ -284,9 +302,15 @@ export function AuthGate({ children }: AuthGateProps) {
}
pendingProtectedActionRef.current = postLoginAction ?? null;
if (isWechatMiniProgramWebViewRuntime()) {
setShowLoginModal(false);
requestMiniProgramLogin();
return;
}
setShowLoginModal(true);
},
[readyUser],
[readyUser, requestMiniProgramLogin],
);
const requireAuth = useCallback(
@@ -425,11 +449,26 @@ export function AuthGate({ children }: AuthGateProps) {
void hydrate(++authHydrateVersionRef.current);
};
const handleAuthHashChange = () => {
const callbackResult = consumeAuthCallbackResult();
if (!callbackResult) {
return;
}
if (callbackResult.error) {
setError(callbackResult.error);
return;
}
setStatus('checking');
void hydrate(++authHydrateVersionRef.current);
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
window.addEventListener('hashchange', handleAuthHashChange);
return () => {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
window.removeEventListener('hashchange', handleAuthHashChange);
};
}, [restoreAuthSession]);

View File

@@ -7,6 +7,9 @@ import type {
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import {
isWechatMiniProgramWebViewRuntime,
} from '../../services/authService';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
@@ -83,6 +86,7 @@ export function LoginScreen({
const passwordLoginEnabled = true;
const phoneLoginEnabled = true;
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
@@ -317,7 +321,7 @@ export function LoginScreen({
</button>
</div>
{wechatLoginEnabled ? (
{wechatLoginEnabled && !miniProgramRuntime ? (
<WechatButton
loading={wechatLoading}
disabled={submitDisabled}
@@ -364,7 +368,8 @@ export function LoginScreen({
{!passwordLoginEnabled &&
!phoneLoginEnabled &&
!wechatLoginEnabled ? (
!wechatLoginEnabled &&
!miniProgramRuntime ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
</div>

View File

@@ -20,8 +20,8 @@ import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
import {
authEntry,
bindWechatPhone,
changePhoneNumber,
changePassword,
changePhoneNumber,
consumeAuthCallbackResult,
getAuthAuditLogs,
getAuthLoginOptions,
@@ -34,6 +34,7 @@ import {
loginWithPhoneCode,
logoutAllAuthSessions,
redeemRegistrationInviteCode,
requestWechatMiniProgramPhoneLogin,
revokeAuthSession,
revokeAuthSessions,
sendPhoneLoginCode,
@@ -408,6 +409,84 @@ describe('authService', () => {
);
});
it('requests mini program phone login by opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
wx: {
miniProgram: {
navigateTo,
},
},
}),
);
const result = await requestWechatMiniProgramPhoneLogin();
expect(result).toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => {
const navigateTo = vi.fn((options: { url: string; success?: () => void }) => {
options.success?.();
});
const scriptListeners = new Map<string, EventListener>();
const existingScript = {
addEventListener: vi.fn(
(type: string, listener: EventListener) => {
scriptListeners.set(type, listener);
},
),
};
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '?clientRuntime=wechat_mini_program',
assign: vi.fn(),
},
}),
);
vi.stubGlobal('document', {
querySelector: vi.fn(() => existingScript),
head: {
appendChild: vi.fn(),
},
createElement: vi.fn(),
});
const request = requestWechatMiniProgramPhoneLogin();
window.wx = {
miniProgram: {
navigateTo,
},
};
scriptListeners.get('load')?.(new Event('load'));
await expect(request).resolves.toBe(true);
expect(navigateTo).toHaveBeenCalledWith({
url: '/pages/web-view/index?authAction=login&returnTo=previous',
success: expect.any(Function),
fail: expect.any(Function),
});
});
it('loads available login methods for the unauthenticated login screen', async () => {
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],

View File

@@ -20,8 +20,8 @@ import type {
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
AuthWechatBindPhoneResponse,
AuthWechatBindPhoneRequest,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
PublicUserSearchResponse,
@@ -55,6 +55,10 @@ export type ConsumedAuthCallback = {
error: string | null;
};
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const MINI_PROGRAM_AUTH_PAGE_URL =
'/pages/web-view/index?authAction=login&returnTo=previous';
// 登录前公开认证入口不能误带旧 token也不能先触发 refresh 探测,
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
const PUBLIC_AUTH_REQUEST_OPTIONS = {
@@ -80,6 +84,92 @@ export function clearRuntimeGuestTokenCache() {
runtimeGuestTokenCache.value = null;
}
export function isWechatMiniProgramWebViewRuntime() {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search || '');
return (
params.get('clientRuntime') === 'wechat_mini_program' ||
params.get('clientType') === 'mini_program' ||
Boolean(window.wx?.miniProgram?.postMessage)
);
}
function loadWechatMiniProgramBridge() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成登录'));
}
if (window.wx?.miniProgram?.navigateTo) {
return Promise.resolve(window.wx);
}
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${WECHAT_JS_SDK_URL}"]`,
);
const complete = () => {
if (window.wx?.miniProgram?.navigateTo) {
resolve(window.wx);
} else {
reject(new Error('请在微信小程序内完成登录'));
}
};
if (existingScript) {
if (window.wx?.miniProgram?.navigateTo) {
complete();
return;
}
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成登录')),
{ once: true },
);
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('请在微信小程序内完成登录'));
document.head.appendChild(script);
});
}
export async function requestWechatMiniProgramPhoneLogin() {
if (!isWechatMiniProgramWebViewRuntime()) {
return false;
}
const wxBridge = await loadWechatMiniProgramBridge();
const miniProgram = wxBridge.miniProgram;
const navigateTo = miniProgram?.navigateTo;
if (typeof navigateTo !== 'function') {
return false;
}
await new Promise<void>((resolve, reject) => {
navigateTo({
url: MINI_PROGRAM_AUTH_PAGE_URL,
success() {
resolve();
},
fail(error) {
reject(
new Error(error?.errMsg || '请在微信小程序内完成登录'),
);
},
});
});
return true;
}
export async function ensureRuntimeGuestToken() {
if (isRuntimeGuestTokenFresh(runtimeGuestTokenCache.value)) {
return runtimeGuestTokenCache.value!;