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:
+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>
|
||||
|
||||
Reference in New Issue
Block a user