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:
John
2026-06-15 15:47:19 -07:00
parent 2e14873f2d
commit 3cd322ccfe
19 changed files with 2141 additions and 156 deletions
+209 -79
View File
@@ -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>