Add WeChat login binding gate and context-aware auth UX.
Stop auto-creating duplicate accounts on OAuth, add bind-or-register gate, PC scan login, mobile open-in-WeChat guide, and fix localhost session cookies. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -91,6 +91,9 @@ H5_ACCESS_PASSWORD=change-me
|
||||
# H5_WECHAT_OAUTH_SCOPE=snsapi_userinfo
|
||||
# 默认回调:{H5_PUBLIC_BASE_URL}/auth/wechat/callback
|
||||
# H5_WECHAT_OAUTH_CALLBACK_URL=https://goo.tkmind.cn/auth/wechat/callback
|
||||
# PC 扫码登录(微信开放平台网站应用,可选;未配置时用手机扫页面链接二维码)
|
||||
# H5_WECHAT_OPEN_APP_ID=
|
||||
# H5_WECHAT_OPEN_APP_SECRET=
|
||||
|
||||
# 本地开发:pnpm dev 会自动启动 server.mjs + vite
|
||||
# 若只跑前端:pnpm dev:vite(需另开 pnpm dev:server)
|
||||
|
||||
@@ -215,6 +215,41 @@ export async function migrateSchema(pool) {
|
||||
`ALTER TABLE h5_users ADD COLUMN signup_source VARCHAR(32) NULL DEFAULT 'password' AFTER workspace_root`,
|
||||
);
|
||||
}
|
||||
|
||||
const oauthStateColumns = [
|
||||
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
|
||||
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
|
||||
['auth_mode', "VARCHAR(8) NOT NULL DEFAULT 'mp' AFTER bind_user_id"],
|
||||
['status', "VARCHAR(16) NOT NULL DEFAULT 'pending' AFTER auth_mode"],
|
||||
['result_kind', 'VARCHAR(32) NULL AFTER status'],
|
||||
['result_token', 'VARCHAR(512) NULL AFTER result_kind'],
|
||||
['result_message', 'VARCHAR(255) NULL AFTER result_token'],
|
||||
];
|
||||
for (const [column, definition] of oauthStateColumns) {
|
||||
if (!(await columnExists(pool, 'h5_wechat_oauth_states', column))) {
|
||||
await pool.query(
|
||||
`ALTER TABLE h5_wechat_oauth_states ADD COLUMN \`${column}\` ${definition}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
app_id VARCHAR(32) NOT NULL,
|
||||
openid VARCHAR(64) NOT NULL,
|
||||
unionid VARCHAR(64) NULL,
|
||||
nickname VARCHAR(128) NULL,
|
||||
avatar_url VARCHAR(512) NULL,
|
||||
return_to VARCHAR(512) NULL,
|
||||
utm_source VARCHAR(64) NULL,
|
||||
utm_medium VARCHAR(64) NULL,
|
||||
utm_campaign VARCHAR(64) NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_wechat_pending_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
export async function initSchema(pool) {
|
||||
|
||||
+25
-1
@@ -668,7 +668,31 @@ CREATE TABLE IF NOT EXISTS h5_wechat_oauth_states (
|
||||
utm_source VARCHAR(64) NULL,
|
||||
utm_medium VARCHAR(64) NULL,
|
||||
utm_campaign VARCHAR(64) NULL,
|
||||
intent VARCHAR(16) NOT NULL DEFAULT 'login',
|
||||
bind_user_id CHAR(36) NULL,
|
||||
auth_mode VARCHAR(8) NOT NULL DEFAULT 'mp',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
result_kind VARCHAR(32) NULL,
|
||||
result_token VARCHAR(512) NULL,
|
||||
result_message VARCHAR(255) NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_wechat_oauth_state_expires (expires_at)
|
||||
KEY idx_wechat_oauth_state_expires (expires_at),
|
||||
KEY idx_wechat_oauth_state_status (status, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds (
|
||||
token VARCHAR(64) PRIMARY KEY,
|
||||
app_id VARCHAR(32) NOT NULL,
|
||||
openid VARCHAR(64) NOT NULL,
|
||||
unionid VARCHAR(64) NULL,
|
||||
nickname VARCHAR(128) NULL,
|
||||
avatar_url VARCHAR(512) NULL,
|
||||
return_to VARCHAR(512) NULL,
|
||||
utm_source VARCHAR(64) NULL,
|
||||
utm_medium VARCHAR(64) NULL,
|
||||
utm_campaign VARCHAR(64) NULL,
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_wechat_pending_expires (expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
+169
-5
@@ -15,6 +15,7 @@ import { createTkmindProxy } from './tkmind-proxy.mjs';
|
||||
import {
|
||||
clearUserSessionCookie,
|
||||
createUserAuth,
|
||||
resolveCookieDomainForRequest,
|
||||
USER_COOKIE,
|
||||
userLoginCookies,
|
||||
userSessionCookie,
|
||||
@@ -340,6 +341,20 @@ function userToken(req) {
|
||||
return parseCookies(req.get('cookie'))[USER_COOKIE];
|
||||
}
|
||||
|
||||
function setUserLoginCookies(res, req, token) {
|
||||
res.set(
|
||||
'Set-Cookie',
|
||||
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||||
);
|
||||
}
|
||||
|
||||
function clearUserLoginCookies(res, req) {
|
||||
res.set(
|
||||
'Set-Cookie',
|
||||
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
|
||||
);
|
||||
}
|
||||
|
||||
async function attachUserSession(req, _res, next) {
|
||||
if (!userAuth) return next();
|
||||
const token = userToken(req);
|
||||
@@ -394,7 +409,7 @@ app.post('/auth/login', jsonBody, async (req, res) => {
|
||||
}
|
||||
return res.status(401).json({ message: result.message });
|
||||
}
|
||||
res.set('Set-Cookie', userLoginCookies(result.token, secure));
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
return res.json({ authenticated: true, user: result.user, mode: 'user' });
|
||||
}
|
||||
|
||||
@@ -460,18 +475,145 @@ app.post('/auth/reset-password', jsonBody, async (req, res) => {
|
||||
app.get('/auth/wechat/config', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!wechatOAuthService?.enabled) {
|
||||
return res.json({ enabled: false, inWechat: isWechatUserAgent(req.get('user-agent') || '') });
|
||||
return res.json({
|
||||
enabled: false,
|
||||
inWechat: isWechatUserAgent(req.get('user-agent') || ''),
|
||||
scanEnabled: false,
|
||||
});
|
||||
}
|
||||
return res.json(wechatOAuthService.publicConfig(req));
|
||||
});
|
||||
|
||||
app.get('/auth/wechat/status', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!userAuth || !wechatOAuthService?.enabled) {
|
||||
return res.json({ enabled: false, bound: false });
|
||||
}
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.status(401).json({ message: '未登录' });
|
||||
const config = loadWechatOAuthConfig();
|
||||
const status = await userAuth.getWechatBindingStatus(me.id, config.appId);
|
||||
return res.json({ enabled: true, ...status });
|
||||
});
|
||||
|
||||
app.get('/auth/wechat/pending/:token', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||||
const pending = await userAuth.getWechatPendingBind(req.params.token);
|
||||
if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' });
|
||||
return res.json({
|
||||
nickname: pending.nickname,
|
||||
avatarUrl: pending.avatar_url,
|
||||
returnTo: pending.return_to || '/',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/wechat/register', jsonBody, async (req, res) => {
|
||||
await userAuthReady;
|
||||
const secure = isSecureRequest(req);
|
||||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||||
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
|
||||
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
|
||||
const result = await userAuth.completeWechatRegister({ pendingToken });
|
||||
if (!result.ok) return res.status(400).json({ message: result.message });
|
||||
if (result.isNewUser && plazaSeo) {
|
||||
void plazaSeo
|
||||
.recordAttribution(
|
||||
{
|
||||
event_type: 'signup',
|
||||
utm_source: result.utmSource || 'wechat',
|
||||
utm_medium: result.utmMedium,
|
||||
utm_campaign: result.utmCampaign,
|
||||
user_id: result.user?.id ?? null,
|
||||
},
|
||||
plazaClientIp(req),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: result.user,
|
||||
returnTo: result.returnTo || '/',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/wechat/bind', jsonBody, async (req, res) => {
|
||||
await userAuthReady;
|
||||
const secure = isSecureRequest(req);
|
||||
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
|
||||
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
|
||||
const username = typeof req.body?.username === 'string' ? req.body.username : '';
|
||||
const password = typeof req.body?.password === 'string' ? req.body.password : '';
|
||||
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '用户名和密码不能为空' });
|
||||
}
|
||||
const result = await userAuth.completeWechatBindAccount({
|
||||
pendingToken,
|
||||
username,
|
||||
password,
|
||||
ip: req.ip,
|
||||
});
|
||||
if (!result.ok) {
|
||||
const status = result.retryAfterMs > 0 ? 429 : 401;
|
||||
if (result.retryAfterMs > 0) {
|
||||
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
||||
}
|
||||
return res.status(status).json({ message: result.message });
|
||||
}
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: result.user,
|
||||
bound: true,
|
||||
returnTo: result.returnTo || '/',
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/auth/wechat/scan/start', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!wechatOAuthService?.enabled) {
|
||||
return res.status(503).json({ message: '微信登录未启用' });
|
||||
}
|
||||
try {
|
||||
const payload = await wechatOAuthService.startScanLogin(req);
|
||||
return res.json(payload);
|
||||
} catch (err) {
|
||||
return res.status(503).json({
|
||||
message: err instanceof Error ? err.message : '微信扫码登录不可用',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/auth/wechat/scan/poll', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!wechatOAuthService?.enabled) {
|
||||
return res.status(503).json({ message: '微信登录未启用' });
|
||||
}
|
||||
const state = typeof req.query?.state === 'string' ? req.query.state : '';
|
||||
if (!state) return res.status(400).json({ message: '缺少扫码状态' });
|
||||
const result = await wechatOAuthService.pollScanLogin(state);
|
||||
if (result.status === 'complete' && result.token) {
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
}
|
||||
return res.json(result);
|
||||
});
|
||||
|
||||
app.get('/auth/wechat/authorize', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (!wechatOAuthService?.enabled) {
|
||||
return res.status(503).json({ message: '微信登录未启用' });
|
||||
}
|
||||
try {
|
||||
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req);
|
||||
let bindUserId = null;
|
||||
const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login';
|
||||
if (intent === 'bind' && userAuth) {
|
||||
const me = await userAuth.getMe(userToken(req));
|
||||
if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' });
|
||||
bindUserId = me.id;
|
||||
}
|
||||
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId });
|
||||
return res.redirect(302, redirectUrl);
|
||||
} catch (err) {
|
||||
console.error('WeChat authorize failed:', err);
|
||||
@@ -491,6 +633,23 @@ app.get('/auth/wechat/callback', async (req, res) => {
|
||||
state: typeof req.query?.state === 'string' ? req.query.state : '',
|
||||
ip: req.ip,
|
||||
});
|
||||
|
||||
if (result.action === 'binding_gate') {
|
||||
const params = new URLSearchParams();
|
||||
params.set('wechat_pending', result.pendingToken);
|
||||
if (result.returnTo && result.returnTo !== '/') {
|
||||
params.set('return_to', result.returnTo);
|
||||
}
|
||||
return res.redirect(302, `/?${params.toString()}`);
|
||||
}
|
||||
|
||||
if (result.action === 'poll_error') {
|
||||
return res.redirect(
|
||||
302,
|
||||
`/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.isNewUser && plazaSeo) {
|
||||
void plazaSeo
|
||||
.recordAttribution(
|
||||
@@ -505,7 +664,12 @@ app.get('/auth/wechat/callback', async (req, res) => {
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
res.set('Set-Cookie', userLoginCookies(result.token, secure));
|
||||
|
||||
if (result.authMode === 'open') {
|
||||
return res.send(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
|
||||
}
|
||||
|
||||
setUserLoginCookies(res, req, result.token);
|
||||
return res.redirect(302, result.returnTo || '/');
|
||||
} catch (err) {
|
||||
console.error('WeChat callback failed:', err);
|
||||
@@ -537,7 +701,7 @@ app.post('/auth/logout', async (req, res) => {
|
||||
const secure = isSecureRequest(req);
|
||||
if (userAuth) {
|
||||
await userAuth.revoke(userToken(req));
|
||||
res.set('Set-Cookie', clearUserSessionCookie(secure));
|
||||
clearUserLoginCookies(res, req);
|
||||
}
|
||||
if (legacyAuth) {
|
||||
legacyAuth.revoke(legacySessionToken(req));
|
||||
|
||||
+1
-3
@@ -183,12 +183,10 @@ export function App() {
|
||||
if (returnTo) {
|
||||
try {
|
||||
const url = new URL(returnTo, window.location.origin);
|
||||
if (url.origin !== window.location.origin) {
|
||||
if (url.origin === window.location.origin) {
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
} catch {
|
||||
// ignore invalid URLs
|
||||
}
|
||||
|
||||
@@ -222,6 +222,20 @@ export async function rememberProjectContext(
|
||||
export type WechatAuthConfig = {
|
||||
enabled: boolean;
|
||||
inWechat?: boolean;
|
||||
scanEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type WechatPendingProfile = {
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
returnTo: string;
|
||||
};
|
||||
|
||||
export type WechatBindingStatus = {
|
||||
enabled: boolean;
|
||||
bound: boolean;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export async function getWechatAuthConfig(): Promise<WechatAuthConfig> {
|
||||
@@ -1133,6 +1147,141 @@ export async function resetPassword(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWechatPendingProfile(token: string): Promise<WechatPendingProfile> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/auth/wechat/pending/${encodeURIComponent(token)}`);
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as WechatPendingProfile & {
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body?.message ?? '绑定会话无效');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function completeWechatRegister(pendingToken: string): Promise<{
|
||||
user: PortalUser;
|
||||
returnTo: string;
|
||||
}> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('/auth/wechat/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pendingToken }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
user?: PortalUser;
|
||||
returnTo?: string;
|
||||
};
|
||||
if (!response.ok || !body?.user) {
|
||||
throw new ApiError(response.status, body?.message ?? '微信注册失败');
|
||||
}
|
||||
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
||||
}
|
||||
|
||||
export async function completeWechatBind(
|
||||
pendingToken: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ user: PortalUser; returnTo: string }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('/auth/wechat/bind', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pendingToken, username, password }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
user?: PortalUser;
|
||||
returnTo?: string;
|
||||
};
|
||||
if (!response.ok || !body?.user) {
|
||||
throw new ApiError(response.status, body?.message ?? '绑定失败');
|
||||
}
|
||||
return { user: body.user, returnTo: body.returnTo ?? '/' };
|
||||
}
|
||||
|
||||
export async function getWechatBindingStatus(): Promise<WechatBindingStatus> {
|
||||
try {
|
||||
const response = await fetch('/auth/wechat/status');
|
||||
if (!response.ok) return { enabled: false, bound: false };
|
||||
return (await response.json()) as WechatBindingStatus;
|
||||
} catch {
|
||||
return { enabled: false, bound: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWechatScanLogin(returnTo?: string): Promise<{
|
||||
state: string;
|
||||
qrUrl: string;
|
||||
expiresInMs: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (returnTo) params.set('return_to', returnTo);
|
||||
const query = params.toString();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/auth/wechat/scan/start${query ? `?${query}` : ''}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
state?: string;
|
||||
qrUrl?: string;
|
||||
expiresInMs?: number;
|
||||
};
|
||||
if (!response.ok || !body?.state || !body?.qrUrl) {
|
||||
throw new ApiError(response.status, body?.message ?? '无法启动扫码登录');
|
||||
}
|
||||
return {
|
||||
state: body.state,
|
||||
qrUrl: body.qrUrl,
|
||||
expiresInMs: body.expiresInMs ?? 600000,
|
||||
};
|
||||
}
|
||||
|
||||
export async function pollWechatScanLogin(state: string): Promise<{
|
||||
status: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
||||
pendingToken?: string;
|
||||
message?: string;
|
||||
}> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/auth/wechat/scan/poll?state=${encodeURIComponent(state)}`);
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
status?: 'pending' | 'complete' | 'binding_gate' | 'error' | 'expired';
|
||||
pendingToken?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body?.message ?? '扫码状态查询失败');
|
||||
}
|
||||
return {
|
||||
status: body?.status ?? 'error',
|
||||
pendingToken: body?.pendingToken,
|
||||
message: body?.message,
|
||||
};
|
||||
}
|
||||
|
||||
export async function register(
|
||||
username: string,
|
||||
password: string,
|
||||
|
||||
+209
-79
@@ -1,9 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMe, getWechatAuthConfig, login, loginLegacy, register, resetPassword } from '../api/client';
|
||||
import {
|
||||
getMe,
|
||||
getWechatAuthConfig,
|
||||
login,
|
||||
loginLegacy,
|
||||
register,
|
||||
resetPassword,
|
||||
} from '../api/client';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import { buildWechatAuthorizeUrl, isWechatContext, readWechatAuthError } from '../utils/wechat';
|
||||
import {
|
||||
buildWechatAuthorizeUrl,
|
||||
isMobileExternalBrowser,
|
||||
isWechatContext,
|
||||
readWechatAuthError,
|
||||
readWechatPendingToken,
|
||||
} from '../utils/wechat';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { resolvePlazaHomeUrl } from '../utils/publicUrl';
|
||||
import { WechatBindGate } from './WechatBindGate';
|
||||
import { WechatDesktopLogin } from './WechatDesktopLogin';
|
||||
import { WechatIcon } from './WechatIcon';
|
||||
import { WechatOpenGuide } from './WechatOpenGuide';
|
||||
|
||||
type Mode = 'login' | 'register' | 'reset';
|
||||
|
||||
@@ -34,9 +51,16 @@ export function AuthView({
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [scanEnabled, setScanEnabled] = useState(false);
|
||||
const [wechatContext, setWechatContext] = useState(() =>
|
||||
isWechatContext({ search: window.location.search }),
|
||||
);
|
||||
const [mobileExternal, setMobileExternal] = useState(() =>
|
||||
isMobileExternalBrowser({ search: window.location.search }),
|
||||
);
|
||||
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
|
||||
const [showScanLogin, setShowScanLogin] = useState(false);
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(() => readWechatPendingToken());
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const fromPlaza = (searchParams.get('utm_source') ?? '').toLowerCase() === 'plaza';
|
||||
const returnTo = searchParams.get('return_to');
|
||||
@@ -52,22 +76,54 @@ export function AuthView({
|
||||
if (legacyMode) return;
|
||||
void getWechatAuthConfig().then((config) => {
|
||||
setWechatEnabled(config.enabled);
|
||||
setScanEnabled(Boolean(config.scanEnabled));
|
||||
setWechatContext((prev) =>
|
||||
isWechatContext({
|
||||
search: window.location.search,
|
||||
serverInWechat: config.inWechat,
|
||||
}) || prev,
|
||||
);
|
||||
setMobileExternal(
|
||||
isMobileExternalBrowser({
|
||||
search: window.location.search,
|
||||
serverInWechat: config.inWechat,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, [legacyMode]);
|
||||
|
||||
const showWechatLogin = !legacyMode && mode === 'login' && wechatContext && wechatEnabled;
|
||||
const isLogin = mode === 'login' && !legacyMode;
|
||||
const showWechatOneClick = isLogin && wechatContext && wechatEnabled;
|
||||
const showMobileWechatGuide = isLogin && mobileExternal && wechatEnabled;
|
||||
const showDesktopWechat = isLogin && wechatEnabled && !wechatContext && !mobileExternal;
|
||||
const isDesktopScanMode = showDesktopWechat && showScanLogin;
|
||||
const showAccountForm =
|
||||
!isDesktopScanMode &&
|
||||
(legacyMode ||
|
||||
mode !== 'login' ||
|
||||
mobileExternal ||
|
||||
showPasswordLogin ||
|
||||
!showWechatOneClick ||
|
||||
showDesktopWechat);
|
||||
|
||||
const switchToPasswordLogin = () => {
|
||||
setShowScanLogin(false);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
const switchToScanLogin = () => {
|
||||
setShowScanLogin(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
};
|
||||
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setConfirmPassword('');
|
||||
setShowScanLogin(false);
|
||||
};
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
@@ -77,6 +133,7 @@ export function AuthView({
|
||||
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
|
||||
utmMedium: params.get('utm_medium') ?? undefined,
|
||||
utmCampaign: params.get('utm_campaign') ?? undefined,
|
||||
intent: 'login',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -119,6 +176,7 @@ export function AuthView({
|
||||
);
|
||||
const user = await login(username, password);
|
||||
const me = await getMe().catch(() => null);
|
||||
sessionStorage.setItem('tkmind_suggest_wechat_bind', '1');
|
||||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败');
|
||||
@@ -149,6 +207,16 @@ export function AuthView({
|
||||
}
|
||||
};
|
||||
|
||||
if (pendingToken) {
|
||||
return (
|
||||
<WechatBindGate
|
||||
pendingToken={pendingToken}
|
||||
returnTo={returnTo}
|
||||
onAuth={onAuth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = legacyMode
|
||||
? { title: 'TKMind', desc: '内部访问入口' }
|
||||
: fromPlaza
|
||||
@@ -201,6 +269,9 @@ export function AuthView({
|
||||
!password.trim() ||
|
||||
!confirmPassword.trim());
|
||||
|
||||
const showAltLogin =
|
||||
isLogin && wechatEnabled && (showMobileWechatGuide || (showDesktopWechat && !showScanLogin));
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
@@ -236,7 +307,24 @@ export function AuthView({
|
||||
<p className="login-desc">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
{showWechatLogin && (
|
||||
{isDesktopScanMode && (
|
||||
<>
|
||||
<WechatDesktopLogin
|
||||
scanEnabled={scanEnabled}
|
||||
returnTo={returnTo}
|
||||
onBindingGate={(token) => setPendingToken(token)}
|
||||
onComplete={() => window.location.reload()}
|
||||
onError={(message) => setError(message)}
|
||||
/>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{success && <p className="login-success">{success}</p>}
|
||||
<button type="button" className="login-mode-switch" onClick={switchToPasswordLogin}>
|
||||
账号密码登录
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showWechatOneClick && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -244,90 +332,132 @@ export function AuthView({
|
||||
onClick={handleWechatLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<WechatIcon className="login-wechat-icon" />
|
||||
微信一键登录
|
||||
</button>
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>或</span>
|
||||
</div>
|
||||
{!showPasswordLogin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="login-link login-password-toggle"
|
||||
onClick={() => setShowPasswordLogin(true)}
|
||||
>
|
||||
已有账号?密码登录
|
||||
</button>
|
||||
) : (
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>或</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="login-fields">
|
||||
{!legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus={!showWechatLogin}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{showAccountForm && (
|
||||
<>
|
||||
<div className="login-fields">
|
||||
{!legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus={isLogin}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'register' && !legacyMode && (
|
||||
<>
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="login-input"
|
||||
type="text"
|
||||
placeholder="显示名称(可选)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="注册邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'register' && !legacyMode && (
|
||||
<>
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'}
|
||||
value={password}
|
||||
autoComplete={
|
||||
mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password'
|
||||
}
|
||||
autoFocus={legacyMode}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="login-input"
|
||||
type="text"
|
||||
placeholder="显示名称(可选)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{success && <p className="login-success">{success}</p>}
|
||||
|
||||
<button type="submit" className="login-btn" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAltLogin && (
|
||||
<div className="login-alt-methods">
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>其他登录方式</span>
|
||||
</div>
|
||||
|
||||
{showDesktopWechat && (
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-secondary"
|
||||
onClick={switchToScanLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<WechatIcon className="login-wechat-icon" />
|
||||
微信扫码登录
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showMobileWechatGuide && (
|
||||
<WechatOpenGuide
|
||||
disabled={submitting}
|
||||
onCopied={() => setSuccess('链接已复制,请在微信中打开')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="注册邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'}
|
||||
value={password}
|
||||
autoComplete={
|
||||
mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password'
|
||||
}
|
||||
autoFocus={legacyMode}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{success && <p className="login-success">{success}</p>}
|
||||
|
||||
<button type="submit" className="login-btn" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
export function ChatView({
|
||||
@@ -144,6 +145,7 @@ export function ChatView({
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
||||
{onLogout && (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
@@ -156,6 +158,8 @@ export function ChatView({
|
||||
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
||||
)}
|
||||
|
||||
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
|
||||
|
||||
{typeof balanceCents === 'number' && balanceCents <= 0 && (
|
||||
<div className="banner banner-error">
|
||||
<span>余额不足,请充值后继续使用</span>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getWechatBindingStatus } from '../api/client';
|
||||
import { buildWechatAuthorizeUrl } from '../utils/wechat';
|
||||
|
||||
export function WechatAccountButton({ returnTo }: { returnTo?: string }) {
|
||||
const [bound, setBound] = useState<boolean | null>(null);
|
||||
const [nickname, setNickname] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatBindingStatus().then((status) => {
|
||||
if (!status.enabled) {
|
||||
setBound(null);
|
||||
return;
|
||||
}
|
||||
setBound(status.bound);
|
||||
setNickname(status.nickname ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (bound === null) return null;
|
||||
|
||||
if (bound) {
|
||||
return (
|
||||
<span className="ghost-btn wechat-account-bound" title="已绑定微信">
|
||||
微信已绑{nickname ? ` · ${nickname}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => {
|
||||
window.location.href = buildWechatAuthorizeUrl({
|
||||
intent: 'bind',
|
||||
returnTo: returnTo ?? window.location.pathname,
|
||||
});
|
||||
}}
|
||||
>
|
||||
绑定微信
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
completeWechatBind,
|
||||
completeWechatRegister,
|
||||
getMe,
|
||||
getWechatPendingProfile,
|
||||
} from '../api/client';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import type { WechatPendingProfile } from '../api/client';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
type Step = 'choose' | 'bind';
|
||||
|
||||
export function WechatBindGate({
|
||||
pendingToken,
|
||||
returnTo,
|
||||
onAuth,
|
||||
}: {
|
||||
pendingToken: string;
|
||||
returnTo?: string | null;
|
||||
onAuth: (
|
||||
user?: PortalUser | null,
|
||||
capabilities?: CapabilityMap,
|
||||
grantedSkills?: string[],
|
||||
) => void;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<WechatPendingProfile | null>(null);
|
||||
const [step, setStep] = useState<Step>('choose');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatPendingProfile(pendingToken)
|
||||
.then((data) => setProfile(data))
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '绑定会话无效');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [pendingToken]);
|
||||
|
||||
const finishAuth = async (user: PortalUser) => {
|
||||
const me = await getMe().catch(() => null);
|
||||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||||
const target = returnTo || profile?.returnTo;
|
||||
if (target && target !== '/') {
|
||||
try {
|
||||
const url = new URL(target, window.location.origin);
|
||||
if (url.origin === window.location.origin) {
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid return target
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewUser = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await completeWechatRegister(pendingToken);
|
||||
await finishAuth(result.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBindExisting = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await completeWechatBind(pendingToken, username, password);
|
||||
await finishAuth(result.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '绑定失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = profile?.nickname?.trim() || '微信用户';
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card wechat-bind-card">
|
||||
<div className="login-brand">
|
||||
{profile?.avatarUrl ? (
|
||||
<img src={profile.avatarUrl} alt="" className="wechat-bind-avatar" />
|
||||
) : (
|
||||
<TKMindAvatar size="md" className="login-brand-avatar" />
|
||||
)}
|
||||
<h1 className="login-title">你好,{displayName}</h1>
|
||||
<p className="login-desc">
|
||||
{step === 'choose'
|
||||
? '请选择如何使用这个微信账号'
|
||||
: '验证已有账号后,微信将与该账号绑定'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && <p className="login-desc">正在加载微信信息…</p>}
|
||||
|
||||
{!loading && step === 'choose' && (
|
||||
<div className="wechat-bind-options">
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-bind-option wechat-bind-option-primary"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleNewUser()}
|
||||
>
|
||||
<strong>我是新用户</strong>
|
||||
<span>快速开始,稍后可设置密码</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-bind-option"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setStep('bind');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<strong>我已有账号</strong>
|
||||
<span>绑定微信,保留原有内容</span>
|
||||
</button>
|
||||
<p className="wechat-bind-hint">
|
||||
绑定后,微信登录将进入此账号,你的 MindSpace 内容不会丢失。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && step === 'bind' && (
|
||||
<form className="wechat-bind-form" onSubmit={handleBindExisting}>
|
||||
<div className="login-fields">
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="login-btn"
|
||||
disabled={submitting || !username.trim() || !password.trim()}
|
||||
>
|
||||
{submitting ? '绑定中…' : '验证并绑定'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-link wechat-bind-back"
|
||||
onClick={() => {
|
||||
setStep('choose');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回选择
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && step === 'choose' && <p className="login-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getWechatBindingStatus } from '../api/client';
|
||||
import { buildWechatAuthorizeUrl } from '../utils/wechat';
|
||||
|
||||
export function WechatBindPrompt({
|
||||
returnTo,
|
||||
onDismiss,
|
||||
}: {
|
||||
returnTo?: string;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [bound, setBound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatBindingStatus().then((status) => {
|
||||
const suggested = sessionStorage.getItem('tkmind_suggest_wechat_bind') === '1';
|
||||
if (suggested) sessionStorage.removeItem('tkmind_suggest_wechat_bind');
|
||||
if (status.enabled && !status.bound && suggested) {
|
||||
setVisible(true);
|
||||
}
|
||||
setBound(status.bound);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!visible || bound) return null;
|
||||
|
||||
const handleBind = () => {
|
||||
window.location.href = buildWechatAuthorizeUrl({
|
||||
intent: 'bind',
|
||||
returnTo: returnTo ?? window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="banner banner-info wechat-bind-banner">
|
||||
<span>绑定微信,下次在微信里可一键登录</span>
|
||||
<button type="button" className="banner-action" onClick={handleBind}>
|
||||
立即绑定
|
||||
</button>
|
||||
<button type="button" className="banner-dismiss" onClick={handleDismiss}>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { buildPageUrlForWechatOpen } from '../utils/wechat';
|
||||
import { WechatScanLogin } from './WechatScanLogin';
|
||||
|
||||
/** PC 浏览器微信登录:优先开放平台扫码,否则展示「页面链接」二维码供手机微信打开 */
|
||||
export function WechatDesktopLogin({
|
||||
scanEnabled,
|
||||
returnTo,
|
||||
onBindingGate,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
scanEnabled: boolean;
|
||||
returnTo?: string | null;
|
||||
onBindingGate: (pendingToken: string) => void;
|
||||
onComplete: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [pageQrDataUrl, setPageQrDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanEnabled) return;
|
||||
const url = buildPageUrlForWechatOpen();
|
||||
void QRCode.toDataURL(url, { margin: 1, width: 180 }).then(setPageQrDataUrl).catch(() => {
|
||||
onError('无法生成微信登录二维码');
|
||||
});
|
||||
}, [scanEnabled, onError]);
|
||||
|
||||
return (
|
||||
<div className="login-wechat-scan-card">
|
||||
<div className="login-wechat-scan-head">
|
||||
<span>微信扫码登录</span>
|
||||
</div>
|
||||
{scanEnabled ? (
|
||||
<WechatScanLogin
|
||||
returnTo={returnTo}
|
||||
onBindingGate={onBindingGate}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<div className="login-wechat-scan-body">
|
||||
{pageQrDataUrl ? (
|
||||
<img src={pageQrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
|
||||
) : (
|
||||
<div className="login-wechat-scan-loading">正在生成二维码…</div>
|
||||
)}
|
||||
<p className="login-wechat-scan-tip">请使用微信扫一扫</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function WechatIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8.5 4C5.46 4 3 6.04 3 8.64c0 1.37.74 2.62 1.94 3.53L4 17l4.2-1.8c.58.1 1.18.16 1.8.16 3.04 0 5.5-2.04 5.5-4.64S11.54 4 8.5 4Zm10 2c-2.55 0-4.62 1.7-4.62 3.8 0 2.1 2.07 3.8 4.62 3.8.5 0 .98-.07 1.43-.2L21 16l-1.55-3.1c.8-.72 1.3-1.68 1.3-2.74C20.75 7.7 18.68 6 16.5 6ZM8.5 5.5c2.2 0 4 1.47 4 3.14 0 1.67-1.8 3.14-4 3.14-.55 0-1.08-.05-1.58-.15l-.32-.07-.95.4.35-1.1-.23-.2C5.2 10.3 4.5 9.5 4.5 8.64 4.5 6.97 6.3 5.5 8.5 5.5Zm8 1.5c1.84 0 3.25 1.22 3.25 2.8 0 .98-.52 1.86-1.35 2.42l-.25.18.4 1.2-1.05-.55-.3.08c-.43.11-.88.17-1.35.17-1.84 0-3.25-1.22-3.25-2.8s1.41-2.8 3.25-2.8Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { buildPageUrlForWechatOpen, copyWechatOpenLink, isIosBrowser } from '../utils/wechat';
|
||||
|
||||
export function WechatOpenGuide({
|
||||
disabled,
|
||||
onCopied,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onCopied?: () => void;
|
||||
}) {
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const ios = isIosBrowser();
|
||||
|
||||
const handleOpen = async () => {
|
||||
const ok = await copyWechatOpenLink();
|
||||
setCopied(ok);
|
||||
if (ok) onCopied?.();
|
||||
setGuideOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-secondary"
|
||||
onClick={() => void handleOpen()}
|
||||
disabled={disabled}
|
||||
>
|
||||
在微信中打开
|
||||
</button>
|
||||
|
||||
{guideOpen && (
|
||||
<div
|
||||
className="wechat-open-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="在微信中打开"
|
||||
onClick={() => setGuideOpen(false)}
|
||||
>
|
||||
<div className="wechat-open-guide" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-open-close"
|
||||
aria-label="关闭"
|
||||
onClick={() => setGuideOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2>请在微信中打开</h2>
|
||||
{copied ? (
|
||||
<p className="wechat-open-success">链接已复制</p>
|
||||
) : (
|
||||
<p className="wechat-open-warning">未能自动复制,请手动复制下方链接</p>
|
||||
)}
|
||||
{ios ? (
|
||||
<ol className="wechat-open-steps">
|
||||
<li>点击浏览器右上角 <strong>···</strong></li>
|
||||
<li>选择 <strong>在微信中打开</strong></li>
|
||||
<li>打开后即可使用微信一键登录</li>
|
||||
</ol>
|
||||
) : (
|
||||
<ol className="wechat-open-steps">
|
||||
<li>打开微信,将链接发给「文件传输助手」或任意聊天</li>
|
||||
<li>在微信里点击该链接</li>
|
||||
<li>打开后即可使用微信一键登录</li>
|
||||
</ol>
|
||||
)}
|
||||
<input
|
||||
className="wechat-open-url"
|
||||
readOnly
|
||||
value={buildPageUrlForWechatOpen()}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-btn"
|
||||
onClick={() => void copyWechatOpenLink().then((ok) => setCopied(ok))}
|
||||
>
|
||||
再次复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { pollWechatScanLogin, startWechatScanLogin } from '../api/client';
|
||||
|
||||
export function WechatScanLogin({
|
||||
returnTo,
|
||||
onBindingGate,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
returnTo?: string | null;
|
||||
onBindingGate: (pendingToken: string) => void;
|
||||
onComplete: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expired, setExpired] = useState(false);
|
||||
const stateRef = useRef<string | null>(null);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
stopPolling();
|
||||
setLoading(true);
|
||||
setExpired(false);
|
||||
setQrDataUrl(null);
|
||||
try {
|
||||
const session = await startWechatScanLogin(returnTo ?? undefined);
|
||||
stateRef.current = session.state;
|
||||
const dataUrl = await QRCode.toDataURL(session.qrUrl, {
|
||||
margin: 1,
|
||||
width: 180,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
const expiresAt = Date.now() + session.expiresInMs;
|
||||
pollRef.current = window.setInterval(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
if (Date.now() > expiresAt) {
|
||||
stopPolling();
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
void pollWechatScanLogin(state)
|
||||
.then((result) => {
|
||||
if (result.status === 'pending') return;
|
||||
stopPolling();
|
||||
if (result.status === 'complete') {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
if (result.status === 'binding_gate' && result.pendingToken) {
|
||||
onBindingGate(result.pendingToken);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
onError(result.message ?? '微信扫码登录失败');
|
||||
})
|
||||
.catch((err) => {
|
||||
stopPolling();
|
||||
onError(err instanceof Error ? err.message : '微信扫码登录失败');
|
||||
});
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : '无法启动扫码登录');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void start();
|
||||
return () => stopPolling();
|
||||
}, [returnTo]);
|
||||
|
||||
return (
|
||||
<div className="login-wechat-scan-body">
|
||||
{loading && <div className="login-wechat-scan-loading">正在生成二维码…</div>}
|
||||
{!loading && qrDataUrl && !expired && (
|
||||
<img src={qrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
|
||||
)}
|
||||
{expired && <div className="login-wechat-scan-loading">二维码已过期</div>}
|
||||
<p className="login-wechat-scan-tip">
|
||||
{expired ? (
|
||||
<button type="button" className="login-link" onClick={() => void start()}>
|
||||
刷新二维码
|
||||
</button>
|
||||
) : (
|
||||
'请使用微信扫一扫,在手机上确认登录'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+336
-1
@@ -1765,7 +1765,7 @@ body,
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
gap: var(--space-lg);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
@@ -1837,6 +1837,10 @@ body,
|
||||
}
|
||||
|
||||
.login-wechat-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -1849,6 +1853,127 @@ body,
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.login-wechat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-wechat-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.login-wechat-secondary:hover:not(:disabled) {
|
||||
border-color: rgba(7, 193, 96, 0.45);
|
||||
background: rgba(7, 193, 96, 0.05);
|
||||
}
|
||||
|
||||
.login-wechat-secondary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-wechat-secondary .login-wechat-icon {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.login-alt-methods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-wechat-scan-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-wechat-scan-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-wechat-scan-close {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-link);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-wechat-scan-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px 16px;
|
||||
}
|
||||
|
||||
.login-wechat-scan-qr {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.login-wechat-scan-loading {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-wechat-scan-tip {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-mode-switch {
|
||||
align-self: center;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 4px 2px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-link);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-mode-switch:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.login-wechat-btn:hover:not(:disabled) {
|
||||
opacity: 0.92;
|
||||
}
|
||||
@@ -1858,6 +1983,215 @@ body,
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-wechat-btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid #07c160;
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.login-wechat-btn-outline:hover:not(:disabled) {
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.login-password-toggle {
|
||||
align-self: center;
|
||||
margin-top: calc(-1 * var(--space-sm));
|
||||
}
|
||||
|
||||
.wechat-bind-card {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.wechat-bind-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: var(--space-xs);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wechat-bind-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.wechat-bind-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.wechat-bind-option strong {
|
||||
font-size: 16px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.wechat-bind-option span {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.wechat-bind-option:hover:not(:disabled) {
|
||||
border-color: var(--color-border-input-focus);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.wechat-bind-option-primary {
|
||||
border-color: rgba(7, 193, 96, 0.45);
|
||||
background: rgba(7, 193, 96, 0.06);
|
||||
}
|
||||
|
||||
.wechat-bind-hint {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wechat-bind-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.wechat-bind-back {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.wechat-scan-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.wechat-scan-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.wechat-scan-qr {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.wechat-scan-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wechat-account-bound {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wechat-open-hint {
|
||||
margin: calc(-1 * var(--space-sm)) 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wechat-open-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.wechat-open-guide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 24px 20px 20px;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-bg-surface);
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wechat-open-guide h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.wechat-open-close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wechat-open-success {
|
||||
margin: 0 0 12px;
|
||||
color: #07c160;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wechat-open-warning {
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-text-error);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wechat-open-steps {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 20px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.wechat-open-steps li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.wechat-open-url {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wechat-bind-banner {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1874,6 +2208,7 @@ body,
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
|
||||
.balance-pill {
|
||||
align-self: center;
|
||||
padding: 6px 10px;
|
||||
|
||||
@@ -3,6 +3,58 @@ export function isWechatBrowser(userAgent = navigator.userAgent) {
|
||||
return /MicroMessenger/i.test(ua) || /WindowsWechat/i.test(ua);
|
||||
}
|
||||
|
||||
export function isMobileBrowser(userAgent = navigator.userAgent) {
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '');
|
||||
}
|
||||
|
||||
export function isIosBrowser(userAgent = navigator.userAgent) {
|
||||
return /iPhone|iPad|iPod/i.test(userAgent || '');
|
||||
}
|
||||
|
||||
/** 手机外部浏览器(Safari / Chrome 等),不在微信内 */
|
||||
export function isMobileExternalBrowser(options?: {
|
||||
userAgent?: string;
|
||||
search?: string;
|
||||
serverInWechat?: boolean;
|
||||
}) {
|
||||
return isMobileBrowser(options?.userAgent) && !isWechatContext(options);
|
||||
}
|
||||
|
||||
/** 构建「在微信中打开」后应访问的链接(带上 from=wechat 以展示一键登录) */
|
||||
export function buildPageUrlForWechatOpen(
|
||||
search = window.location.search,
|
||||
pathname = window.location.pathname,
|
||||
) {
|
||||
const params = new URLSearchParams(search);
|
||||
params.set('from', 'wechat');
|
||||
if (!params.get('utm_source')) params.set('utm_source', 'wechat');
|
||||
const query = params.toString();
|
||||
return `${window.location.origin}${pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export async function copyWechatOpenLink(): Promise<boolean> {
|
||||
const url = buildPageUrlForWechatOpen();
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const input = document.createElement('textarea');
|
||||
input.value = url;
|
||||
input.setAttribute('readonly', 'true');
|
||||
input.style.position = 'fixed';
|
||||
input.style.left = '-9999px';
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从服务号链接进入时带的参数(即使 UA 识别失败也展示微信登录) */
|
||||
export function isWechatEntryUrl(search = window.location.search) {
|
||||
const params = new URLSearchParams(search);
|
||||
@@ -26,6 +78,8 @@ export function buildWechatAuthorizeUrl(options?: {
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
intent?: 'login' | 'register' | 'bind';
|
||||
authMode?: 'mp' | 'open';
|
||||
}) {
|
||||
const params = new URLSearchParams();
|
||||
const returnTo =
|
||||
@@ -37,10 +91,23 @@ export function buildWechatAuthorizeUrl(options?: {
|
||||
if (options?.utmSource) params.set('utm_source', options.utmSource);
|
||||
if (options?.utmMedium) params.set('utm_medium', options.utmMedium);
|
||||
if (options?.utmCampaign) params.set('utm_campaign', options.utmCampaign);
|
||||
if (options?.intent) params.set('intent', options.intent);
|
||||
if (options?.authMode) params.set('auth_mode', options.authMode);
|
||||
const query = params.toString();
|
||||
return query ? `/auth/wechat/authorize?${query}` : '/auth/wechat/authorize';
|
||||
}
|
||||
|
||||
export function readWechatPendingToken(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const pending = params.get('wechat_pending');
|
||||
if (!pending) return null;
|
||||
params.delete('wechat_pending');
|
||||
const nextSearch = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState({}, '', nextUrl);
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function readWechatAuthError(): string | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const error = params.get('wechat_error');
|
||||
|
||||
+380
-29
@@ -1519,6 +1519,194 @@ export function createUserAuth(pool, options = {}) {
|
||||
await repairAllUserPublishDirs();
|
||||
};
|
||||
|
||||
const PENDING_BIND_TTL_MS = 15 * 60 * 1000;
|
||||
|
||||
const issueUserSession = async (userId, role, now = Date.now()) => {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(userId, role, token, now);
|
||||
return token;
|
||||
};
|
||||
|
||||
const findBindingByOpenid = async (appId, openid) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT wi.user_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.app_id = ? AND wi.openid = ?
|
||||
LIMIT 1`,
|
||||
[appId, openid],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const findBindingByUnionid = async (unionid) => {
|
||||
if (!unionid) return null;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT wi.user_id, wi.app_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.unionid = ?
|
||||
LIMIT 1`,
|
||||
[unionid],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const getWechatBindingForUser = async (userId, appId) => {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, nickname, avatar_url, last_login_at, created_at
|
||||
FROM h5_user_wechat_identities
|
||||
WHERE user_id = ? AND app_id = ?
|
||||
LIMIT 1`,
|
||||
[userId, appId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
|
||||
const bindWechatToUser = async ({
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const existingOpenid = await findBindingByOpenid(appId, openid);
|
||||
if (existingOpenid && existingOpenid.user_id !== userId) {
|
||||
return { ok: false, message: '该微信已绑定其他账号,请先用该账号登录' };
|
||||
}
|
||||
const existingUserBind = await getWechatBindingForUser(userId, appId);
|
||||
if (existingUserBind) {
|
||||
return { ok: false, message: '你的账号已绑定其他微信,需先解绑后再试' };
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO h5_user_wechat_identities
|
||||
(id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '微信绑定冲突,请重试' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const touchWechatIdentity = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await pool.query(
|
||||
`UPDATE h5_user_wechat_identities
|
||||
SET nickname = COALESCE(?, nickname),
|
||||
avatar_url = COALESCE(?, avatar_url),
|
||||
unionid = COALESCE(?, unionid),
|
||||
last_login_at = ?,
|
||||
updated_at = ?
|
||||
WHERE app_id = ? AND openid = ?`,
|
||||
[nickname, avatarUrl, unionid, now, now, appId, openid],
|
||||
);
|
||||
};
|
||||
|
||||
const pruneWechatPendingBinds = async (now = Date.now()) => {
|
||||
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
|
||||
};
|
||||
|
||||
const createWechatPendingBind = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo = '/',
|
||||
utmSource = null,
|
||||
utmMedium = null,
|
||||
utmCampaign = null,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await pruneWechatPendingBinds(now);
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
await pool.query(
|
||||
`INSERT INTO h5_wechat_pending_binds
|
||||
(token, app_id, openid, unionid, nickname, avatar_url, return_to,
|
||||
utm_source, utm_medium, utm_campaign, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
token,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
now + PENDING_BIND_TTL_MS,
|
||||
now,
|
||||
],
|
||||
);
|
||||
return token;
|
||||
};
|
||||
|
||||
const getWechatPendingBind = async (token, now = Date.now()) => {
|
||||
if (!token) return null;
|
||||
await pruneWechatPendingBinds(now);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to,
|
||||
utm_source, utm_medium, utm_campaign, expires_at
|
||||
FROM h5_wechat_pending_binds
|
||||
WHERE token = ?
|
||||
LIMIT 1`,
|
||||
[token],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row || Number(row.expires_at) <= now) return null;
|
||||
return row;
|
||||
};
|
||||
|
||||
const consumeWechatPendingBind = async (token) => {
|
||||
await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
|
||||
};
|
||||
|
||||
const loginBoundWechatUser = async ({
|
||||
userId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
|
||||
const user = await getUserById(userId);
|
||||
if (!user || user.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
const token = await issueUserSession(user.id, user.role, now);
|
||||
return { ok: true, token, user: publicUser(user), isNewUser: false };
|
||||
};
|
||||
|
||||
const generateWechatUsername = async (openid) => {
|
||||
const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
||||
const suffix = cleaned.slice(-8) || crypto.randomBytes(4).toString('hex');
|
||||
@@ -1621,70 +1809,223 @@ export function createUserAuth(pool, options = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const loginByWechat = async ({
|
||||
const resolveWechatAuth = async ({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
ip = 'unknown',
|
||||
intent = 'login',
|
||||
bindUserId = null,
|
||||
returnTo = '/',
|
||||
utmSource = null,
|
||||
utmMedium = null,
|
||||
utmCampaign = null,
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const [bindings] = await pool.query(
|
||||
`SELECT wi.user_id, u.status
|
||||
FROM h5_user_wechat_identities wi
|
||||
JOIN h5_users u ON u.id = wi.user_id
|
||||
WHERE wi.app_id = ? AND wi.openid = ?
|
||||
LIMIT 1`,
|
||||
[appId, openid],
|
||||
);
|
||||
const binding = bindings[0];
|
||||
let binding = await findBindingByOpenid(appId, openid);
|
||||
|
||||
if (!binding && unionid) {
|
||||
const unionBinding = await findBindingByUnionid(unionid);
|
||||
if (unionBinding) {
|
||||
const linked = await bindWechatToUser({
|
||||
userId: unionBinding.user_id,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!linked.ok) return linked;
|
||||
binding = { user_id: unionBinding.user_id, status: unionBinding.status };
|
||||
}
|
||||
}
|
||||
|
||||
if (binding) {
|
||||
if (binding.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE h5_user_wechat_identities
|
||||
SET nickname = COALESCE(?, nickname),
|
||||
avatar_url = COALESCE(?, avatar_url),
|
||||
unionid = COALESCE(?, unionid),
|
||||
last_login_at = ?,
|
||||
updated_at = ?
|
||||
WHERE app_id = ? AND openid = ?`,
|
||||
[nickname, avatarUrl, unionid, now, now, appId, openid],
|
||||
);
|
||||
const user = await getUserById(binding.user_id);
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(user.id, user.role, token, now);
|
||||
return { ok: true, token, user: publicUser(user), isNewUser: false };
|
||||
return loginBoundWechatUser({
|
||||
userId: binding.user_id,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
}).then((result) => (result.ok ? { ...result, action: 'login' } : result));
|
||||
}
|
||||
|
||||
const registered = await registerViaWechat({
|
||||
if (intent === 'bind' && bindUserId) {
|
||||
const user = await getUserById(bindUserId);
|
||||
if (!user) return { ok: false, message: '用户不存在' };
|
||||
if (user.status === 'disabled') {
|
||||
return { ok: false, message: '账户已禁用,请联系管理员' };
|
||||
}
|
||||
const bound = await bindWechatToUser({
|
||||
userId: bindUserId,
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!bound.ok) return bound;
|
||||
const token = await issueUserSession(user.id, user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'login',
|
||||
token,
|
||||
user: publicUser(user),
|
||||
isNewUser: false,
|
||||
bound: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'register') {
|
||||
const registered = await registerViaWechat({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
now,
|
||||
});
|
||||
if (!registered.ok) return registered;
|
||||
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
action: 'login',
|
||||
token,
|
||||
user: registered.user,
|
||||
isNewUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingToken = await createWechatPendingBind({
|
||||
appId,
|
||||
openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
now,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
action: 'binding_gate',
|
||||
pendingToken,
|
||||
wechatProfile: {
|
||||
nickname: nickname ?? null,
|
||||
avatarUrl: avatarUrl ?? null,
|
||||
},
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
};
|
||||
};
|
||||
|
||||
const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
|
||||
const pending = await getWechatPendingBind(pendingToken, now);
|
||||
if (!pending) {
|
||||
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
|
||||
}
|
||||
const registered = await registerViaWechat({
|
||||
appId: pending.app_id,
|
||||
openid: pending.openid,
|
||||
unionid: pending.unionid,
|
||||
nickname: pending.nickname,
|
||||
avatarUrl: pending.avatar_url,
|
||||
now,
|
||||
});
|
||||
if (!registered.ok) return registered;
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
await storeSession(registered.user.id, registered.user.role, token, now);
|
||||
await consumeWechatPendingBind(pendingToken);
|
||||
const token = await issueUserSession(registered.user.id, registered.user.role, now);
|
||||
return {
|
||||
ok: true,
|
||||
token,
|
||||
user: registered.user,
|
||||
isNewUser: true,
|
||||
returnTo: pending.return_to || '/',
|
||||
utmSource: pending.utm_source,
|
||||
utmMedium: pending.utm_medium,
|
||||
utmCampaign: pending.utm_campaign,
|
||||
};
|
||||
};
|
||||
|
||||
const completeWechatBindAccount = async ({
|
||||
pendingToken,
|
||||
username,
|
||||
password,
|
||||
ip = 'unknown',
|
||||
now = Date.now(),
|
||||
}) => {
|
||||
const pending = await getWechatPendingBind(pendingToken, now);
|
||||
if (!pending) {
|
||||
return { ok: false, message: '绑定会话已过期,请重新微信登录' };
|
||||
}
|
||||
const loginResult = await login({ username, password, ip, now });
|
||||
if (!loginResult.ok) return loginResult;
|
||||
|
||||
const bound = await bindWechatToUser({
|
||||
userId: loginResult.user.id,
|
||||
appId: pending.app_id,
|
||||
openid: pending.openid,
|
||||
unionid: pending.unionid,
|
||||
nickname: pending.nickname,
|
||||
avatarUrl: pending.avatar_url,
|
||||
now,
|
||||
});
|
||||
if (!bound.ok) return bound;
|
||||
|
||||
await consumeWechatPendingBind(pendingToken);
|
||||
return {
|
||||
ok: true,
|
||||
token: loginResult.token,
|
||||
user: loginResult.user,
|
||||
isNewUser: false,
|
||||
bound: true,
|
||||
returnTo: pending.return_to || '/',
|
||||
};
|
||||
};
|
||||
|
||||
const getWechatBindingStatus = async (userId, appId) => {
|
||||
const row = await getWechatBindingForUser(userId, appId);
|
||||
if (!row) return { bound: false };
|
||||
return {
|
||||
bound: true,
|
||||
nickname: row.nickname,
|
||||
avatarUrl: row.avatar_url,
|
||||
lastLoginAt: Number(row.last_login_at),
|
||||
boundAt: Number(row.created_at),
|
||||
};
|
||||
};
|
||||
|
||||
const loginByWechat = async (params) => {
|
||||
const result = await resolveWechatAuth({ ...params, intent: 'login' });
|
||||
if (!result.ok) return result;
|
||||
if (result.action === 'binding_gate') {
|
||||
return { ok: false, message: '需要完成账号绑定' };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
USER_COOKIE,
|
||||
register,
|
||||
login,
|
||||
loginByWechat,
|
||||
resolveWechatAuth,
|
||||
completeWechatRegister,
|
||||
completeWechatBindAccount,
|
||||
getWechatPendingBind,
|
||||
getWechatBindingStatus,
|
||||
resetPassword,
|
||||
verify,
|
||||
revoke,
|
||||
@@ -1785,3 +2126,13 @@ export function resolveCookieDomain() {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 本地 localhost 开发时不设置 Domain,否则浏览器不会保存跨域 cookie。 */
|
||||
export function resolveCookieDomainForRequest(req) {
|
||||
const hostHeader = req?.get?.('host') ?? req?.hostname ?? '';
|
||||
const hostname = String(hostHeader).split(':')[0].toLowerCase();
|
||||
if (!hostname || hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||
return null;
|
||||
}
|
||||
return resolveCookieDomain();
|
||||
}
|
||||
|
||||
+223
-37
@@ -2,10 +2,13 @@ import crypto from 'node:crypto';
|
||||
import { fetch } from 'undici';
|
||||
|
||||
const OAUTH_AUTHORIZE_URL = 'https://open.weixin.qq.com/connect/oauth2/authorize';
|
||||
const OAUTH_QRCONNECT_URL = 'https://open.weixin.qq.com/connect/qrconnect';
|
||||
const OAUTH_ACCESS_TOKEN_URL = 'https://api.weixin.qq.com/sns/oauth2/access_token';
|
||||
const OAUTH_USERINFO_URL = 'https://api.weixin.qq.com/sns/userinfo';
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const VALID_INTENTS = new Set(['login', 'register', 'bind']);
|
||||
const VALID_AUTH_MODES = new Set(['mp', 'open']);
|
||||
|
||||
function resolveCallbackUrl(env = process.env) {
|
||||
const explicit = env.H5_WECHAT_OAUTH_CALLBACK_URL?.trim() ?? '';
|
||||
@@ -24,13 +27,19 @@ export function loadWechatOAuthConfig(env = process.env) {
|
||||
const enabledFlag = env.H5_WECHAT_OAUTH_ENABLED === '1';
|
||||
const appId = env.H5_WECHAT_APP_ID?.trim() ?? env.H5_WECHAT_OAUTH_APP_ID?.trim() ?? '';
|
||||
const appSecret = env.H5_WECHAT_APP_SECRET?.trim() ?? env.H5_WECHAT_OAUTH_APP_SECRET?.trim() ?? '';
|
||||
const openAppId = env.H5_WECHAT_OPEN_APP_ID?.trim() ?? '';
|
||||
const openAppSecret = env.H5_WECHAT_OPEN_APP_SECRET?.trim() ?? '';
|
||||
const scope = env.H5_WECHAT_OAUTH_SCOPE?.trim() || 'snsapi_userinfo';
|
||||
const callbackUrl = resolveCallbackUrl(env);
|
||||
const configured = Boolean(appId && appSecret && callbackUrl);
|
||||
const scanConfigured = Boolean(openAppId && openAppSecret && callbackUrl);
|
||||
return {
|
||||
enabled: enabledFlag && configured,
|
||||
appId,
|
||||
appSecret,
|
||||
openAppId,
|
||||
openAppSecret,
|
||||
scanEnabled: enabledFlag && scanConfigured,
|
||||
scope,
|
||||
callbackUrl,
|
||||
};
|
||||
@@ -50,21 +59,34 @@ export function sanitizeWechatReturnTo(returnTo, req) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(config, { state }) {
|
||||
const params = new URLSearchParams({
|
||||
appid: config.appId,
|
||||
redirect_uri: config.callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: config.scope,
|
||||
state,
|
||||
});
|
||||
return `${OAUTH_AUTHORIZE_URL}?${params.toString()}#wechat_redirect`;
|
||||
function normalizeIntent(intent) {
|
||||
const value = typeof intent === 'string' ? intent.trim().toLowerCase() : 'login';
|
||||
return VALID_INTENTS.has(value) ? value : 'login';
|
||||
}
|
||||
|
||||
async function exchangeCode(config, code) {
|
||||
function normalizeAuthMode(authMode) {
|
||||
const value = typeof authMode === 'string' ? authMode.trim().toLowerCase() : 'mp';
|
||||
return VALID_AUTH_MODES.has(value) ? value : 'mp';
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(config, { state, authMode = 'mp' }) {
|
||||
const isOpen = authMode === 'open';
|
||||
const params = new URLSearchParams({
|
||||
appid: config.appId,
|
||||
secret: config.appSecret,
|
||||
appid: isOpen ? config.openAppId : config.appId,
|
||||
redirect_uri: config.callbackUrl,
|
||||
response_type: 'code',
|
||||
scope: isOpen ? 'snsapi_login' : config.scope,
|
||||
state,
|
||||
});
|
||||
const base = isOpen ? OAUTH_QRCONNECT_URL : OAUTH_AUTHORIZE_URL;
|
||||
return `${base}?${params.toString()}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async function exchangeCode(config, code, authMode = 'mp') {
|
||||
const isOpen = authMode === 'open';
|
||||
const params = new URLSearchParams({
|
||||
appid: isOpen ? config.openAppId : config.appId,
|
||||
secret: isOpen ? config.openAppSecret : config.appSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
@@ -81,6 +103,7 @@ async function exchangeCode(config, code) {
|
||||
openid: data.openid,
|
||||
unionid: data.unionid ?? null,
|
||||
refreshToken: data.refresh_token ?? null,
|
||||
appId: isOpen ? config.openAppId : config.appId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,7 +130,7 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
||||
return {
|
||||
enabled: false,
|
||||
publicConfig() {
|
||||
return { enabled: false, inWechat: false };
|
||||
return { enabled: false, inWechat: false, scanEnabled: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -122,6 +145,9 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
||||
utmSource = null,
|
||||
utmMedium = null,
|
||||
utmCampaign = null,
|
||||
intent = 'login',
|
||||
bindUserId = null,
|
||||
authMode = 'mp',
|
||||
now = Date.now(),
|
||||
} = {}) => {
|
||||
const state = crypto.randomBytes(24).toString('base64url');
|
||||
@@ -129,93 +155,246 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
||||
await pruneExpiredStates(now);
|
||||
await pool.query(
|
||||
`INSERT INTO h5_wechat_oauth_states
|
||||
(state, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[state, returnTo, utmSource, utmMedium, utmCampaign, now + STATE_TTL_MS, now],
|
||||
(state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
|
||||
auth_mode, status, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
||||
[
|
||||
state,
|
||||
returnTo,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
normalizeIntent(intent),
|
||||
bindUserId,
|
||||
normalizeAuthMode(authMode),
|
||||
now + STATE_TTL_MS,
|
||||
now,
|
||||
],
|
||||
);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const consumeState = async (state, now = Date.now()) => {
|
||||
if (!state) return null;
|
||||
if (!pool) return null;
|
||||
const loadState = async (state, now = Date.now()) => {
|
||||
if (!state || !pool) return null;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, expires_at
|
||||
`SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id,
|
||||
auth_mode, status, result_kind, result_token, result_message, expires_at
|
||||
FROM h5_wechat_oauth_states
|
||||
WHERE state = ?
|
||||
LIMIT 1`,
|
||||
[state],
|
||||
);
|
||||
const row = rows[0];
|
||||
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
|
||||
if (!row || Number(row.expires_at) <= now) return null;
|
||||
return row;
|
||||
};
|
||||
|
||||
const consumeState = async (state, now = Date.now()) => {
|
||||
const row = await loadState(state, now);
|
||||
if (!row) return null;
|
||||
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
|
||||
return {
|
||||
returnTo: row.return_to || '/',
|
||||
utmSource: row.utm_source,
|
||||
utmMedium: row.utm_medium,
|
||||
utmCampaign: row.utm_campaign,
|
||||
intent: row.intent || 'login',
|
||||
bindUserId: row.bind_user_id,
|
||||
authMode: row.auth_mode || 'mp',
|
||||
};
|
||||
};
|
||||
|
||||
const buildAuthorizeRedirect = async (req) => {
|
||||
const markStateComplete = async (
|
||||
state,
|
||||
{ resultKind, resultToken = null, resultMessage = null },
|
||||
now = Date.now(),
|
||||
) => {
|
||||
await pool.query(
|
||||
`UPDATE h5_wechat_oauth_states
|
||||
SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ?
|
||||
WHERE state = ?`,
|
||||
[resultKind, resultToken, resultMessage, now + STATE_TTL_MS, state],
|
||||
);
|
||||
};
|
||||
|
||||
const buildAuthorizeRedirect = async (req, { bindUserId = null } = {}) => {
|
||||
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
|
||||
const intent = normalizeIntent(req.query?.intent);
|
||||
const authMode = normalizeAuthMode(req.query?.auth_mode);
|
||||
if (authMode === 'open' && !config.scanEnabled) {
|
||||
throw new Error('微信扫码登录未配置');
|
||||
}
|
||||
const state = await createState({
|
||||
returnTo,
|
||||
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
|
||||
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
|
||||
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
|
||||
intent,
|
||||
bindUserId,
|
||||
authMode,
|
||||
});
|
||||
return buildAuthorizeUrl(config, { state, authMode });
|
||||
};
|
||||
|
||||
const startScanLogin = async (req) => {
|
||||
if (!config.scanEnabled) {
|
||||
throw new Error('微信扫码登录未配置');
|
||||
}
|
||||
const returnTo = sanitizeWechatReturnTo(req.query?.return_to, req);
|
||||
const state = await createState({
|
||||
returnTo,
|
||||
utmSource: typeof req.query?.utm_source === 'string' ? req.query.utm_source : null,
|
||||
utmMedium: typeof req.query?.utm_medium === 'string' ? req.query.utm_medium : null,
|
||||
utmCampaign: typeof req.query?.utm_campaign === 'string' ? req.query.utm_campaign : null,
|
||||
intent: 'login',
|
||||
authMode: 'open',
|
||||
});
|
||||
return buildAuthorizeUrl(config, { state });
|
||||
return {
|
||||
state,
|
||||
qrUrl: buildAuthorizeUrl(config, { state, authMode: 'open' }),
|
||||
expiresInMs: STATE_TTL_MS,
|
||||
};
|
||||
};
|
||||
|
||||
const pollScanLogin = async (state, now = Date.now()) => {
|
||||
const row = await loadState(state, now);
|
||||
if (!row) return { status: 'expired' };
|
||||
if (row.status !== 'done') return { status: 'pending' };
|
||||
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
|
||||
if (row.result_kind === 'error') {
|
||||
return { status: 'error', message: row.result_message || '微信登录失败' };
|
||||
}
|
||||
if (row.result_kind === 'binding_gate') {
|
||||
return { status: 'binding_gate', pendingToken: row.result_token };
|
||||
}
|
||||
if (row.result_kind === 'login') {
|
||||
return { status: 'complete', token: row.result_token };
|
||||
}
|
||||
return { status: 'error', message: '微信登录结果无效' };
|
||||
};
|
||||
|
||||
const handleCallback = async ({ code, state, ip = 'unknown' }) => {
|
||||
if (!code) {
|
||||
throw new Error('缺少微信授权 code');
|
||||
}
|
||||
const stateRow = await consumeState(state);
|
||||
const stateRow = await loadState(state);
|
||||
if (!stateRow) {
|
||||
throw new Error('授权状态无效或已过期,请重新登录');
|
||||
}
|
||||
|
||||
const tokenInfo = await exchangeCode(config, code);
|
||||
const authMode = stateRow.auth_mode || 'mp';
|
||||
const tokenInfo = await exchangeCode(config, code, authMode);
|
||||
let nickname = null;
|
||||
let avatarUrl = null;
|
||||
let unionid = tokenInfo.unionid;
|
||||
|
||||
if (config.scope === 'snsapi_userinfo') {
|
||||
if (authMode === 'mp' && config.scope === 'snsapi_userinfo') {
|
||||
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
|
||||
nickname = profile.nickname;
|
||||
avatarUrl = profile.avatarUrl;
|
||||
unionid = profile.unionid ?? unionid;
|
||||
}
|
||||
if (authMode === 'open') {
|
||||
try {
|
||||
const profile = await fetchUserInfo(tokenInfo.accessToken, tokenInfo.openid);
|
||||
nickname = profile.nickname;
|
||||
avatarUrl = profile.avatarUrl;
|
||||
unionid = profile.unionid ?? unionid;
|
||||
} catch {
|
||||
// snsapi_login may not always return userinfo; proceed with openid only
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAuth?.loginByWechat) {
|
||||
if (!userAuth?.resolveWechatAuth) {
|
||||
throw new Error('用户系统未就绪');
|
||||
}
|
||||
|
||||
const result = await userAuth.loginByWechat({
|
||||
appId: config.appId,
|
||||
const result = await userAuth.resolveWechatAuth({
|
||||
appId: tokenInfo.appId,
|
||||
openid: tokenInfo.openid,
|
||||
unionid,
|
||||
nickname,
|
||||
avatarUrl,
|
||||
intent: stateRow.intent || 'login',
|
||||
bindUserId: stateRow.bind_user_id,
|
||||
returnTo: stateRow.return_to || '/',
|
||||
utmSource: stateRow.utm_source,
|
||||
utmMedium: stateRow.utm_medium,
|
||||
utmCampaign: stateRow.utm_campaign,
|
||||
ip,
|
||||
utmSource: stateRow.utmSource,
|
||||
utmMedium: stateRow.utmMedium,
|
||||
utmCampaign: stateRow.utmCampaign,
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (authMode === 'open') {
|
||||
await markStateComplete(state, {
|
||||
resultKind: 'error',
|
||||
resultMessage: result.message || '微信登录失败',
|
||||
});
|
||||
return {
|
||||
authMode,
|
||||
action: 'poll_error',
|
||||
message: result.message || '微信登录失败',
|
||||
};
|
||||
}
|
||||
throw new Error(result.message || '微信登录失败');
|
||||
}
|
||||
|
||||
if (result.action === 'binding_gate') {
|
||||
if (authMode === 'open') {
|
||||
await markStateComplete(state, {
|
||||
resultKind: 'binding_gate',
|
||||
resultToken: result.pendingToken,
|
||||
});
|
||||
return {
|
||||
authMode,
|
||||
action: 'binding_gate',
|
||||
pendingToken: result.pendingToken,
|
||||
wechatProfile: result.wechatProfile,
|
||||
returnTo: result.returnTo,
|
||||
};
|
||||
}
|
||||
return {
|
||||
authMode,
|
||||
action: 'binding_gate',
|
||||
pendingToken: result.pendingToken,
|
||||
wechatProfile: result.wechatProfile,
|
||||
returnTo: result.returnTo,
|
||||
utmSource: result.utmSource,
|
||||
utmMedium: result.utmMedium,
|
||||
utmCampaign: result.utmCampaign,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMode === 'open') {
|
||||
await markStateComplete(state, {
|
||||
resultKind: 'login',
|
||||
resultToken: result.token,
|
||||
});
|
||||
return {
|
||||
authMode,
|
||||
action: 'login',
|
||||
token: result.token,
|
||||
user: result.user,
|
||||
isNewUser: result.isNewUser,
|
||||
returnTo: stateRow.return_to || '/',
|
||||
utmSource: stateRow.utm_source,
|
||||
utmMedium: stateRow.utm_medium,
|
||||
utmCampaign: stateRow.utm_campaign,
|
||||
};
|
||||
}
|
||||
|
||||
await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]);
|
||||
return {
|
||||
authMode,
|
||||
action: 'login',
|
||||
token: result.token,
|
||||
user: result.user,
|
||||
isNewUser: result.isNewUser,
|
||||
returnTo: stateRow.returnTo,
|
||||
utmSource: stateRow.utmSource,
|
||||
utmMedium: stateRow.utmMedium,
|
||||
utmCampaign: stateRow.utmCampaign,
|
||||
bound: result.bound,
|
||||
returnTo: stateRow.return_to || '/',
|
||||
utmSource: stateRow.utm_source,
|
||||
utmMedium: stateRow.utm_medium,
|
||||
utmCampaign: stateRow.utm_campaign,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -223,9 +402,16 @@ export function createWechatOAuthService(pool, config, { userAuth } = {}) {
|
||||
enabled: true,
|
||||
publicConfig(req) {
|
||||
const inWechat = req ? isWechatUserAgent(req.get?.('user-agent') || '') : false;
|
||||
return { enabled: true, inWechat };
|
||||
return {
|
||||
enabled: true,
|
||||
inWechat,
|
||||
scanEnabled: Boolean(config.scanEnabled),
|
||||
appId: config.appId,
|
||||
};
|
||||
},
|
||||
buildAuthorizeRedirect,
|
||||
startScanLogin,
|
||||
pollScanLogin,
|
||||
handleCallback,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user