fix: defer mini program phone auth until login
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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!;
|
||||
|
||||
Reference in New Issue
Block a user