HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径: parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调 pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash 那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack() 不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方 原本直接读的 window.location.hash(这个类从来没包装过这个读取)。 一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里 的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫 会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理 这种情况,对外的调用方(19个文件)完全无感。 tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate; service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行, 没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用 都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误 资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只 更新了注释。 实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回" 按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/ 个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
866 lines
48 KiB
React
866 lines
48 KiB
React
import React from 'react';
|
||
|
||
import Navigation from '../lib/navigation.js';
|
||
import { BASE_APP_NAME } from '../config.js';
|
||
import {
|
||
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
|
||
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
|
||
createSecurityChallenge, verifySecurityChallenge, getRealnameStatus, submitRealname, setIndependentPassword,
|
||
getYouthMode, enableYouthMode
|
||
} from '../lib/im-api.js';
|
||
|
||
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
|
||
const PHONE_RE = /^\d{11}$/;
|
||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
|
||
// params is optional and additive only -- every existing call site that passes just `view` keeps
|
||
// producing the exact same bare hash as the old `Navigation.navigateTo(view)` did.
|
||
const navigate = (view, params) => Navigation.navigateTo(Navigation.composeUrl([view], params || {}));
|
||
|
||
function Icon({children, className = ''}) {
|
||
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
|
||
}
|
||
|
||
function AppMark({tenantName}) {
|
||
return <div className="auth-brand">
|
||
<div className="auth-logo"><span>{BASE_APP_NAME[0]}</span></div>
|
||
<h1>{tenantName || BASE_APP_NAME}</h1>
|
||
</div>;
|
||
}
|
||
|
||
function Header({title, back = 'tenant'}) {
|
||
return <header className="auth-header">
|
||
<button type="button" className="auth-icon-button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回">
|
||
<Icon>arrow_back_ios_new</Icon>
|
||
</button>
|
||
<h1>{title}</h1>
|
||
<span className="auth-header-spacer" />
|
||
</header>;
|
||
}
|
||
|
||
function Field({icon, onIconClick, action, error, className = '', ...props}) {
|
||
return <div className={'auth-field-wrap ' + className}>
|
||
<div className={'auth-field' + (error ? ' invalid' : '')}>
|
||
{icon ? (onIconClick ?
|
||
<button type="button" className="auth-field-icon-button" onClick={onIconClick}><Icon>{icon}</Icon></button> :
|
||
<Icon className="auth-field-icon">{icon}</Icon>) : null}
|
||
<input {...props} />
|
||
{action}
|
||
</div>
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function PasswordField(props) {
|
||
const [visible, setVisible] = React.useState(false);
|
||
return <Field {...props} type={visible ? 'text' : 'password'}
|
||
action={<button type="button" className="auth-inline-icon" onClick={() => setVisible(!visible)}
|
||
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
|
||
}
|
||
|
||
function Agreement({checked, onChange, prefix = `同意${BASE_APP_NAME}`}) {
|
||
return <label className="auth-agreement">
|
||
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
|
||
<span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
|
||
</label>;
|
||
}
|
||
|
||
function Toast({message, onDone}) {
|
||
React.useEffect(() => {
|
||
if (!message) return undefined;
|
||
const timer = window.setTimeout(onDone, 2800);
|
||
return () => window.clearTimeout(timer);
|
||
}, [message, onDone]);
|
||
return message ? <div className="auth-toast" role="status"><Icon>check_circle</Icon>{message}</div> : null;
|
||
}
|
||
|
||
// Shared alert for errors coming back from a real API/WS call (as opposed to local form validation,
|
||
// which each form already reports inline per-field).
|
||
function FormAlert({text}) {
|
||
return text ? <div className="auth-form-alert"><Icon>error_outline</Icon>{text}</div> : null;
|
||
}
|
||
|
||
function TenantEntry({onTenantResolved}) {
|
||
const [code, setCode] = React.useState(sessionStorage.getItem('im-tenant-code') || '');
|
||
const [error, setError] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
const value = code.trim();
|
||
if (!value || value.length > 64) {
|
||
setError(value ? '企业码不能超过 64 个字符' : '请输入企业码');
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError('');
|
||
try {
|
||
const data = await resolveTenant(value);
|
||
// Kept for the mock UI's own display of the tenant name while on this screen; the durable
|
||
// context (read by every subsequent /v1/im/* call and by the {hi} tenant field) is persisted
|
||
// by onTenantResolved.
|
||
sessionStorage.setItem('im-tenant-code', data.tenant.code || value);
|
||
sessionStorage.setItem('im-tenant-name', data.tenant.name || data.tenant.code || value);
|
||
onTenantResolved(data, value);
|
||
navigate('login');
|
||
} catch (err) {
|
||
// Per the product spec, tenant-resolve failures are shown as one generic message
|
||
// regardless of reason (not found vs. disabled vs. rate limited).
|
||
setError('企业码不存在或已停用,请核实后重试');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return <div className="auth-landing">
|
||
<button className="auth-top-link" type="button" onClick={() => navigate('enterprise-register')}>企业注册</button>
|
||
<form className="auth-landing-form" onSubmit={submit}>
|
||
<AppMark />
|
||
<Field value={code} onChange={e => { setCode(e.target.value); setError(''); }}
|
||
placeholder="请输入企业码" autoComplete="organization" maxLength="64" error={error} autoFocus />
|
||
<button className="auth-primary" type="submit" disabled={!code.trim() || busy}>{busy ? '进入中…' : '进入企业'}</button>
|
||
</form>
|
||
<button className="auth-bottom-link" type="button" onClick={() => navigate('enterprise-feedback')}>遇到问题?</button>
|
||
</div>;
|
||
}
|
||
|
||
function Login({tenantName, login, loginDisabled, persist, errorText, errorLevel, onLoginRequest, onPersistenceChange, onError}) {
|
||
const [account, setAccount] = React.useState(login || '');
|
||
const [password, setPassword] = React.useState('');
|
||
const [agreed, setAgreed] = React.useState(false);
|
||
|
||
// The prototype (docs/images/登录页面.png) has no "stay logged in" checkbox -- a consumer IM app
|
||
// just keeps you signed in. Opt in silently instead of exposing the old debug-era toggle.
|
||
React.useEffect(() => {
|
||
if (!persist) {
|
||
onPersistenceChange(true);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Real login failures (wrong password, disabled account, ...) clear the password and keep the account,
|
||
// matching the product spec.
|
||
React.useEffect(() => {
|
||
if (errorLevel === 'err' && errorText) {
|
||
setPassword('');
|
||
}
|
||
}, [errorText, errorLevel]);
|
||
|
||
const submit = e => {
|
||
e.preventDefault();
|
||
if (!account.trim() || !password || !agreed || loginDisabled) return;
|
||
onLoginRequest(account.trim(), password);
|
||
};
|
||
|
||
return <div className="auth-landing auth-login-page">
|
||
<button type="button" className="auth-back-link"
|
||
onClick={() => { onError && onError(); navigate('tenant'); }}><Icon>arrow_back_ios_new</Icon>切换企业</button>
|
||
<form className="auth-landing-form" onSubmit={submit}>
|
||
<AppMark tenantName={tenantName} />
|
||
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
|
||
<Field value={account} onChange={e => setAccount(e.target.value)}
|
||
placeholder="请输入用户名/手机号" autoComplete="username" />
|
||
<PasswordField value={password} onChange={e => setPassword(e.target.value)}
|
||
placeholder="请输入密码" autoComplete="current-password" />
|
||
<div className="auth-row auth-row-end"><button type="button" className="auth-text-button" onClick={() => navigate('reset')}>忘记密码</button></div>
|
||
<button className="auth-primary" type="submit" disabled={!account.trim() || !password || !agreed || loginDisabled}>
|
||
{loginDisabled ? '登录中…' : '登录'}
|
||
</button>
|
||
<p className="auth-switch">没有账户? <button type="button" onClick={() => navigate('register')}>马上注册</button></p>
|
||
</form>
|
||
<Agreement checked={agreed} onChange={setAgreed} />
|
||
</div>;
|
||
}
|
||
|
||
function EnterpriseRegister() {
|
||
const [creditCode, setCreditCode] = React.useState('');
|
||
const [email, setEmail] = React.useState('');
|
||
const [file, setFile] = React.useState(null);
|
||
const [errors, setErrors] = React.useState({});
|
||
const [submitting, setSubmitting] = React.useState(false);
|
||
const [submitted, setSubmitted] = React.useState(false);
|
||
const [reviewDays, setReviewDays] = React.useState(1);
|
||
const [toast, setToast] = React.useState('');
|
||
|
||
React.useEffect(() => () => file && URL.revokeObjectURL(file.url), [file]);
|
||
|
||
const chooseFile = e => {
|
||
const selected = e.target.files[0];
|
||
if (!selected) return;
|
||
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
|
||
setErrors({...errors, file: '支持 JPG、PNG、WebP、PDF,文件不超过 10MB'});
|
||
return;
|
||
}
|
||
if (file) URL.revokeObjectURL(file.url);
|
||
setFile({name: selected.name, type: selected.type, url: URL.createObjectURL(selected), blob: selected});
|
||
setErrors({...errors, file: ''});
|
||
};
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
const next = {};
|
||
if (!CREDIT_CODE_RE.test(creditCode)) next.creditCode = '请输入 18 位统一社会信用代码';
|
||
if (email && !EMAIL_RE.test(email)) next.email = '请输入有效的企业邮箱';
|
||
setErrors(next);
|
||
if (Object.keys(next).length) return;
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
let licenseFileId = '';
|
||
if (file) {
|
||
const uploaded = await uploadPublicFile(file.blob, 'enterprise_license');
|
||
licenseFileId = uploaded.file_id;
|
||
}
|
||
const data = await submitTenantApplication({
|
||
credit_code: creditCode,
|
||
enterprise_email: email,
|
||
license_file_id: licenseFileId,
|
||
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || ''
|
||
});
|
||
// Prototype (docs/images/企业注册-填写注册信息.png) states this in business days, not hours.
|
||
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
|
||
setSubmitted(true);
|
||
setToast('提交成功,我们会尽快联系你');
|
||
} catch (err) {
|
||
setErrors({...next, form: err.message || '提交失败,请稍后重试'});
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return <div className="auth-page">
|
||
<Header title="企业注册" />
|
||
<main className="auth-content auth-content-narrow">
|
||
{/* .active marks a step (and the connector after it) as reached, not "this is the current
|
||
step" -- steps stay lit once passed. There's no separate server-tracked "确定信息" state
|
||
(submitting the form *is* confirming it), so step 2 only ever gets skipped straight from
|
||
1 to 3; without submitted also keeping step 1 lit, submitting made it look like step 1 had
|
||
been undone instead of completed. */}
|
||
<ol className="auth-steps">
|
||
<li className="active"><span>1</span><b>填写企业信息</b></li>
|
||
<li className={submitted ? 'active' : ''}><span>2</span><b>确定信息</b></li>
|
||
<li className={submitted ? 'active' : ''}><span>3</span><b>提交审核</b></li>
|
||
</ol>
|
||
<form className="auth-form" onSubmit={submit}>
|
||
<FormAlert text={errors.form} />
|
||
<label className="auth-label">统一社会信用代码</label>
|
||
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
|
||
placeholder="请输入18位统一社会信用代码" maxLength="18" error={errors.creditCode}
|
||
action={<button type="button" className="auth-inline-icon"
|
||
onClick={() => setToast('扫码识别暂不可用,请手动输入')}><Icon>qr_code_scanner</Icon></button>} />
|
||
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
|
||
|
||
<label className="auth-label">上传营业执照 <span>选填</span></label>
|
||
<label className={'auth-upload' + (errors.file ? ' invalid' : '')}>
|
||
<input type="file" accept="image/jpeg,image/png,image/webp,application/pdf" onChange={chooseFile} />
|
||
{file && file.type !== 'application/pdf' ? <img src={file.url} alt="营业执照预览" /> : <Icon>add_photo_alternate</Icon>}
|
||
<strong>{file ? file.name : '点击上传营业执照'}</strong>
|
||
<small>支持 JPG、PNG、WebP、PDF,不超过 10MB</small>
|
||
</label>
|
||
{file ? <button type="button" className="auth-remove-file" onClick={() => { URL.revokeObjectURL(file.url); setFile(null); }}>移除文件</button> : null}
|
||
{errors.file ? <div className="auth-field-error">{errors.file}</div> : null}
|
||
|
||
<label className="auth-label">企业邮箱 <span>选填</span></label>
|
||
<Field icon="mail_outline" value={email} onChange={e => setEmail(e.target.value)}
|
||
placeholder="请输入企业邮箱" type="email" error={errors.email} />
|
||
<p className="auth-help">用于接收审核结果及企业通知</p>
|
||
<button className="auth-primary" type="submit" disabled={submitted || submitting || creditCode.length !== 18}>
|
||
{submitted ? '已提交' : submitting ? '提交中…' : '提交资料'}
|
||
</button>
|
||
<p className="auth-review-time"><Icon>schedule</Icon>资料提交后,预计 {reviewDays} 个工作日完成审核</p>
|
||
</form>
|
||
</main>
|
||
<Toast message={toast} onDone={() => setToast('')} />
|
||
</div>;
|
||
}
|
||
|
||
// Per docs/images/企业注册-问题反馈.png: titled "意见反馈", 300-char textarea with the instructions
|
||
// as its placeholder (not a separate label), "图片(N/9)" counter-style label, and no contact field.
|
||
function EnterpriseFeedback() {
|
||
const [description, setDescription] = React.useState('');
|
||
const [files, setFiles] = React.useState([]);
|
||
const [error, setError] = React.useState('');
|
||
const [submitting, setSubmitting] = React.useState(false);
|
||
const [toast, setToast] = React.useState('');
|
||
|
||
const addFiles = e => {
|
||
const selected = Array.from(e.target.files || []).slice(0, 9 - files.length);
|
||
const valid = selected.filter(file => file.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(file.type));
|
||
setFiles(files.concat(valid.map(file => ({name: file.name, url: URL.createObjectURL(file), blob: file}))));
|
||
};
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
const length = description.trim().length;
|
||
if (length < 10 || length > 300) {
|
||
setError('问题描述需为 10 至 300 个字符');
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
const screenshotFileIds = [];
|
||
for (const item of files) {
|
||
const uploaded = await uploadPublicFile(item.blob, 'tenant_entry_feedback');
|
||
screenshotFileIds.push(uploaded.file_id);
|
||
}
|
||
await submitTenantEntryFeedback({
|
||
description: description.trim(),
|
||
tenant_code_hint: sessionStorage.getItem('im-tenant-code') || '',
|
||
screenshot_file_ids: screenshotFileIds
|
||
});
|
||
setToast('已收到反馈,我们会尽快处理');
|
||
files.forEach(item => URL.revokeObjectURL(item.url));
|
||
setDescription(''); setFiles([]); setError('');
|
||
} catch (err) {
|
||
setError(err.message || '提交失败,请稍后重试');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return <div className="auth-page auth-soft-bg">
|
||
<Header title="意见反馈" />
|
||
<main className="auth-content auth-content-narrow">
|
||
<form className="auth-form" onSubmit={submit}>
|
||
<label className="auth-label">问题反馈</label>
|
||
<textarea value={description} maxLength="300" onChange={e => { setDescription(e.target.value); setError(''); }}
|
||
placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" className={error ? 'invalid' : ''} />
|
||
<div className="auth-counter">{description.length}/300</div>
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
<label className="auth-label">图片({files.length}/9)</label>
|
||
<div className="auth-screenshot-grid">
|
||
{files.map((file, index) => <div className="auth-screenshot" key={file.url}>
|
||
<img src={file.url} alt={file.name} />
|
||
<button type="button" onClick={() => { URL.revokeObjectURL(file.url); setFiles(files.filter((_, i) => i !== index)); }} aria-label="删除截图"><Icon>close</Icon></button>
|
||
</div>)}
|
||
{files.length < 9 ? <label className="auth-add-screenshot"><input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={addFiles} /><Icon>photo_camera</Icon><span>点击上传</span></label> : null}
|
||
</div>
|
||
<button className="auth-primary" type="submit" disabled={description.trim().length < 10 || submitting}>{submitting ? '提交中…' : '提交'}</button>
|
||
</form>
|
||
</main>
|
||
<Toast message={toast} onDone={() => setToast('')} />
|
||
</div>;
|
||
}
|
||
|
||
// Requests a phone verification code (scene: 'register' | 'reset_password' | 'security') and reports the
|
||
// resulting challenge_id upward -- the caller needs it to verify the code the user types in.
|
||
function VerificationButton({phone, scene = 'register', onChallenge, onError}) {
|
||
const [seconds, setSeconds] = React.useState(0);
|
||
const [busy, setBusy] = React.useState(false);
|
||
React.useEffect(() => {
|
||
if (!seconds) return undefined;
|
||
const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
|
||
return () => window.clearTimeout(timer);
|
||
}, [seconds]);
|
||
|
||
const click = async () => {
|
||
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
|
||
setBusy(true);
|
||
try {
|
||
const data = await requestVerificationCode(scene, phone);
|
||
onChallenge(data.challenge_id);
|
||
setSeconds(data.resend_after_seconds || 60);
|
||
} catch (err) {
|
||
onError && onError(err.message || '获取验证码失败');
|
||
if (err.details && err.details.retry_after_seconds) {
|
||
setSeconds(err.details.retry_after_seconds);
|
||
}
|
||
if (err.details && err.details.challenge_id) {
|
||
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
|
||
onChallenge(err.details.challenge_id);
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return <button type="button" className="auth-code-button" disabled={busy || seconds > 0 || !PHONE_RE.test(phone)} onClick={click}>
|
||
{seconds > 0 ? `${seconds}s` : busy ? '发送中…' : '获取验证码'}
|
||
</button>;
|
||
}
|
||
|
||
// Per the actual prototype screenshots (docs/images/注册页面.png), all fields are shown together --
|
||
// no phone-vs-username mode toggle. "Phone takes precedence" (per the spec text) is validation-only:
|
||
// if a phone is filled it must be verified; the invite code is only required for a username-only
|
||
// signup (matching the mock's original submit-time validation, now with real backend calls behind it).
|
||
function Register({errorText, errorLevel, persist, onImRegisterRequest, onPersistenceChange}) {
|
||
const [form, setForm] = React.useState({username: '', phone: '', code: '', password: '', invite: ''});
|
||
const [challengeId, setChallengeId] = React.useState('');
|
||
const [agreed, setAgreed] = React.useState(false);
|
||
const [errors, setErrors] = React.useState({});
|
||
const [submitting, setSubmitting] = React.useState(false);
|
||
const change = key => e => setForm({...form, [key]: e.target.value});
|
||
const usingPhone = !!form.phone;
|
||
|
||
// Same silent "stay logged in" opt-in Login has (see its own copy of this effect for why there's
|
||
// no visible checkbox) -- missing here meant every account created by registering (as opposed to
|
||
// logging in) never had keep-logged-in/auth-token set, so handleLoginSuccessful() never persisted
|
||
// the session: the very next refresh silently lost myUserId with no redirect to the login page
|
||
// and no error, just a permanently empty messages-home shell. Every fresh registration hit this on
|
||
// its first reload.
|
||
React.useEffect(() => {
|
||
if (!persist) {
|
||
onPersistenceChange(true);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// onImRegisterRequest (tinode-web.jsx's handleImRegisterRequest -> a WS connect+createAccountBasic
|
||
// chain) isn't awaited and doesn't return a promise -- on the username/invite-code path (no
|
||
// verifyRegisterCode await ahead of it) `submit` returns and its `finally` clears `submitting`
|
||
// within milliseconds, long before that WS round-trip finishes, leaving the button re-clickable
|
||
// and able to fire a second concurrent registration. The parent's only failure signal is these
|
||
// props changing, so hold `submitting` until either that happens or the phone-path's own catch
|
||
// already handled it (success unmounts this component regardless).
|
||
React.useEffect(() => {
|
||
if (errorLevel === 'err' && errorText) setSubmitting(false);
|
||
}, [errorText, errorLevel]);
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
const next = {};
|
||
if (!form.username && !form.phone) next.username = '用户名和手机号至少填写一项';
|
||
if (form.username && !USERNAME_RE.test(form.username)) next.username = '用户名仅支持 1-10 位小写字母或数字';
|
||
if (usingPhone) {
|
||
if (!PHONE_RE.test(form.phone)) next.phone = '请输入 11 位手机号';
|
||
if (!challengeId) next.code = '请先获取验证码';
|
||
else if (!/^\d{6}$/.test(form.code)) next.code = '请输入 6 位验证码';
|
||
} else if (!INVITE_RE.test(form.invite)) {
|
||
next.invite = '用户名注册需填写 5 位字母邀请码';
|
||
}
|
||
if (form.password.length < 8) next.password = '密码至少 8 位';
|
||
setErrors(next);
|
||
if (Object.keys(next).length) return;
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
if (usingPhone) {
|
||
const verified = await verifyRegisterCode(challengeId, form.code);
|
||
// The username field is optional even on the phone path -- filling in both used to silently
|
||
// drop the typed username (always sent '' and always logged in as the phone), because the
|
||
// server only auto-assigns a random handle when username is actually empty
|
||
// (server/user.go resolveRegistrationAccount: username!="" && phone!="" => account=username,
|
||
// secretAccount=username; only username=="" falls back to the phone-derived random handle).
|
||
// Per the protocol spec, the phone number itself must never become the display name -- leave
|
||
// it blank when no username was typed either (server assigns a random handle; the user can
|
||
// set a nickname afterwards).
|
||
const secretAccount = form.username || ('+86' + form.phone);
|
||
onImRegisterRequest(secretAccount, form.password, form.username || '',
|
||
// 邀请码不能在手机号路径上丢掉:后端 user.go:175-181 对邀请码的校验和 RecordUse 都不区分
|
||
// 注册路径(`if inviteCode != ""` 就会校验并记录),而且 user.go:169 的
|
||
// inviteRequired 在短信通道关闭时对手机号注册同样为真。UI 上明明摆着邀请码输入框,
|
||
// 原来却硬传空串,用户填了也被静默丢弃,邀请来源统计全丢。
|
||
{username: form.username || '', phone: form.phone, invite_code: form.invite || ''},
|
||
{meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}});
|
||
} else {
|
||
onImRegisterRequest(form.username, form.password, form.username,
|
||
{username: form.username, phone: '', invite_code: form.invite}, null);
|
||
}
|
||
// On success the parent (tinode-web.jsx handleImRegisterRequest -> handleLoginSuccessful)
|
||
// navigates away (unmounting this component, so `submitting` no longer matters); on failure
|
||
// it surfaces errorText/errorLevel, which the effect above clears `submitting` on. No
|
||
// `finally` here on purpose -- onImRegisterRequest isn't awaited (it's not a promise), so a
|
||
// `finally` would clear `submitting` within milliseconds on the username/invite-code path,
|
||
// long before that request's own WS round-trip resolves, defeating the double-submit guard.
|
||
} catch (err) {
|
||
setErrors({...next, code: err.message || '验证码错误或已过期'});
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return <div className="auth-landing auth-register-page">
|
||
<form className="auth-landing-form auth-register-form" onSubmit={submit}>
|
||
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || BASE_APP_NAME} />
|
||
<FormAlert text={errorLevel === 'err' ? errorText : ''} />
|
||
<Field value={form.username} onChange={e => setForm({...form, username: e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10)})}
|
||
placeholder="请输入用户名" error={errors.username} autoComplete="username" />
|
||
<Field value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})}
|
||
placeholder="请输入手机号" error={errors.phone} inputMode="numeric" />
|
||
<Field value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})}
|
||
placeholder="请输入短信验证码" inputMode="numeric" error={errors.code}
|
||
action={<VerificationButton phone={form.phone} scene="register" onChallenge={setChallengeId}
|
||
onError={msg => setErrors(e => ({...e, code: msg}))} />} />
|
||
<PasswordField value={form.password} onChange={change('password')} placeholder="请输入密码" autoComplete="new-password" error={errors.password} />
|
||
{/* 邀请码大小写敏感(server/store/... 的唯一索引是 ascii_bin 排序),不能像信用代码那样
|
||
.toUpperCase() 统一大小写;autoCapitalize="off" 只是防止移动端输入法把首字母偷偷转大写,
|
||
没有它,一个真实有效、首字母是小写的邀请码会被静默改错且用户毫无察觉。 */}
|
||
<Field value={form.invite} onChange={e => setForm({...form, invite: e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5)})}
|
||
placeholder="请输入邀请码(非必填)" error={errors.invite} autoCapitalize="off" autoCorrect="off" />
|
||
{/* 需求文档 1.4 第5条硬性要求:"用户名方式不发送短信,必须填写有效邀请码;注册前提示
|
||
'未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置'"——原来这句话在整个
|
||
注册表单里完全没有出现过(不是视觉遗漏,是从未写过对应的 JSX),用户名注册的人完全不知道
|
||
自己放弃了手机号找回密码的能力。只在用户名路径(未填手机号)显示,不打扰手机号注册路径
|
||
(那条路径本来就能自助找回密码,不需要这条提醒),参考图(注册页面.png)本身是两个字段
|
||
都还没交互的初始态,不代表这条路径相关提示不应该出现。 */}
|
||
{!usingPhone ? <p className="auth-help">未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置</p> : null}
|
||
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
|
||
{submitting ? '注册中…' : '注册'}
|
||
</button>
|
||
<p className="auth-switch">已有{BASE_APP_NAME}账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
|
||
</form>
|
||
<Agreement checked={agreed} onChange={setAgreed} prefix={`注册即认同${BASE_APP_NAME}`} />
|
||
</div>;
|
||
}
|
||
|
||
function PasswordReset() {
|
||
const [form, setForm] = React.useState({phone: '', code: '', password: '', confirm: ''});
|
||
const [challengeId, setChallengeId] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [submitting, setSubmitting] = React.useState(false);
|
||
const [toast, setToast] = React.useState('');
|
||
const change = key => e => setForm({...form, [key]: e.target.value});
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
if (!PHONE_RE.test(form.phone)) return setError('请输入 11 位手机号');
|
||
if (!challengeId || !/^\d{6}$/.test(form.code)) return setError('请先获取并输入 6 位验证码');
|
||
// Matches Register's rule (>= 8, no upper bound -- the backend's own checkPasswordPolicy only
|
||
// enforces a minimum length). This page used to also reject anything over 20 characters, a limit
|
||
// invented here with no backend counterpart: a password set during registration could exceed
|
||
// it, then resetting it back to that same value through this flow would be rejected -- a
|
||
// self-inflicted lockout for password reset's own sake.
|
||
if (form.password.length < 8) return setError('密码至少 8 位');
|
||
if (form.password !== form.confirm) return setError('两次输入的密码不一致');
|
||
|
||
setSubmitting(true);
|
||
setError('');
|
||
try {
|
||
const verified = await verifyPasswordResetCode(challengeId, form.code);
|
||
await confirmPasswordReset(verified.reset_token, form.password);
|
||
setToast('密码修改成功,请重新登录');
|
||
window.setTimeout(() => navigate('login'), 1200);
|
||
} catch (err) {
|
||
setError(err.message || '验证码错误或已过期');
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
return <div className="auth-page auth-soft-bg">
|
||
<Header title="忘记密码" back="login" />
|
||
<main className="auth-content auth-content-narrow auth-reset-content">
|
||
<form className="auth-list-form" onSubmit={submit}>
|
||
<label><span>手机号</span><input value={form.phone} onChange={e => setForm({...form, phone: e.target.value.replace(/\D/g, '').slice(0, 11)})} placeholder="请输入手机号" inputMode="numeric" /></label>
|
||
<label><span>验证码</span><input value={form.code} onChange={e => setForm({...form, code: e.target.value.replace(/\D/g, '').slice(0, 6)})} placeholder="请输入验证码" inputMode="numeric" />
|
||
<VerificationButton phone={form.phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} /></label>
|
||
<div className="auth-list-gap" />
|
||
<label><span>设置密码</span><input type="password" value={form.password} onChange={change('password')} placeholder="至少 8 位" /></label>
|
||
<label><span>再次输入</span><input type="password" value={form.confirm} onChange={change('confirm')} placeholder="请再次确定密码" /></label>
|
||
{error ? <div className="auth-form-alert"><Icon>error_outline</Icon>{error}</div> : null}
|
||
<button className="auth-primary auth-sticky-action" type="submit"
|
||
disabled={!form.phone || !form.code || !form.password || !form.confirm || submitting}>
|
||
{submitting ? '提交中…' : '修改密码'}
|
||
</button>
|
||
</form>
|
||
</main>
|
||
<Toast message={toast} onDone={() => setToast('')} />
|
||
</div>;
|
||
}
|
||
|
||
// Precedes 1.11 (setting the independent password): resolves the account's own verified phone
|
||
// server-side (POST /v1/im/security/challenges never takes a phone number from the client) and, on
|
||
// successful verify, stashes the resulting action_token for the next screen to spend.
|
||
const IDENTITY_CHECK_SCENE = 'set_independent_password';
|
||
|
||
function IdentityCheck({authenticated, hashParams}) {
|
||
// Carries through whichever youth-mode flow (enable/disable) sent the user here via "忘记独立
|
||
// 密码?", if any -- IndependentPassword needs it to know whether to auto-continue into actually
|
||
// enabling youth mode, since the server hands back an enable-flow token unconditionally on every
|
||
// successful password (re)set regardless of why the caller got here. Absent (undefined) for the
|
||
// unrelated generic "身份验证" entry in acc-security-view.jsx, which should never touch youth mode.
|
||
const mode = hashParams && hashParams.ym;
|
||
const [phoneMask, setPhoneMask] = React.useState('');
|
||
const [challengeId, setChallengeId] = React.useState('');
|
||
const [code, setCode] = React.useState('');
|
||
const [seconds, setSeconds] = React.useState(0);
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [error, setError] = React.useState('');
|
||
|
||
React.useEffect(() => {
|
||
if (!seconds) return undefined;
|
||
const timer = window.setTimeout(() => setSeconds(s => s - 1), 1000);
|
||
return () => window.clearTimeout(timer);
|
||
}, [seconds]);
|
||
|
||
const requestCode = React.useCallback(async () => {
|
||
setBusy(true);
|
||
setError('');
|
||
try {
|
||
const data = await createSecurityChallenge(IDENTITY_CHECK_SCENE);
|
||
setChallengeId(data.challenge_id);
|
||
setPhoneMask(data.phone_mask);
|
||
setSeconds(data.resend_after_seconds || 60);
|
||
} catch (err) {
|
||
setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败'));
|
||
// 限流(429)时服务端会连同还没过期的 challenge_id 和剩余冷却秒数一起返回
|
||
// (im_http.go:6361-6367)。原来这里只把错误文案显示出来就完了,challengeId 一直是空串,
|
||
// 于是 submit() 开头的 `if (!challengeId) return` 会静默吞掉点击——按钮看着能点,
|
||
// 输完 6 位验证码点"确定"毫无反应,也没有任何提示。同文件的 VerificationButton
|
||
// 早就处理对了,这里是漏抄。
|
||
if (err.details && err.details.retry_after_seconds) {
|
||
setSeconds(err.details.retry_after_seconds);
|
||
}
|
||
if (err.details && err.details.challenge_id) {
|
||
setChallengeId(err.details.challenge_id);
|
||
}
|
||
if (err.details && err.details.phone_mask) {
|
||
setPhoneMask(err.details.phone_mask);
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}, []);
|
||
|
||
React.useEffect(() => { requestCode(); }, [requestCode]);
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
if (!challengeId || code.length !== 6 || busy) return;
|
||
setBusy(true);
|
||
try {
|
||
const data = await verifySecurityChallenge(challengeId, code);
|
||
sessionStorage.setItem('im-action-token:' + IDENTITY_CHECK_SCENE, JSON.stringify(data));
|
||
navigate('independent-password', mode ? {ym: mode} : undefined);
|
||
} catch (err) {
|
||
setError(err.message || '验证码错误');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return <div className="auth-page auth-soft-bg auth-identity-check-page">
|
||
<Header title="身份验证" back={authenticated ? 'security' : 'login'} />
|
||
<main className="auth-content auth-content-narrow auth-reset-content">
|
||
<form className="auth-list-form" onSubmit={submit}>
|
||
<label><span>手机号</span><input value={phoneMask} readOnly placeholder="获取中…" /></label>
|
||
<label><span>验证码</span>
|
||
<input value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="请输入验证码" inputMode="numeric" />
|
||
<button type="button" className="auth-code-button" disabled={busy || seconds > 0} onClick={requestCode}>
|
||
{seconds > 0 ? `${seconds}s` : '获取验证码'}
|
||
</button>
|
||
</label>
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
<button className="auth-primary auth-sticky-action" type="submit" disabled={code.length !== 6 || busy}>确定</button>
|
||
</form>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
function Realname({authenticated = false}) {
|
||
// Real status (unverified/pending/verified/rejected) drives the view -- the old mock's `verified`
|
||
// prop toggle is gone, both 'realname' and 'realname-verified' routes render this and let the
|
||
// GET /v1/im/security/realname response decide what to show.
|
||
const [status, setStatus] = React.useState('loading');
|
||
const [maskedName, setMaskedName] = React.useState('');
|
||
const [maskedIdNumber, setMaskedIdNumber] = React.useState('');
|
||
const [name, setName] = React.useState('');
|
||
const [idNumber, setIdNumber] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
React.useEffect(() => {
|
||
getRealnameStatus().then(data => {
|
||
setStatus(data.status);
|
||
setMaskedName(data.masked_name || '');
|
||
setMaskedIdNumber(data.masked_id_number || '');
|
||
}).catch(err => { setError(err.message || '加载实名状态失败'); setStatus('load_error'); });
|
||
}, []);
|
||
|
||
const editable = status === 'unverified' || status === 'rejected';
|
||
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
if (name.trim().length < 2 || !/^\d{17}[\dXx]$/.test(idNumber)) {
|
||
setError('请输入有效姓名和 18 位身份证号码'); return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
// Backend's validIMRealName only accepts the literal 'cn_id' -- 'id_card' was rejected outright
|
||
// with a 400 every time, so real-name submission never actually worked.
|
||
const data = await submitRealname({name: name.trim(), id_type: 'cn_id', id_number: idNumber.toUpperCase()});
|
||
setStatus(data.status || 'pending');
|
||
setError('');
|
||
// The submissions POST only ever returns {status, submission_id} -- never masked_name/
|
||
// masked_id_number -- but GET /security/realname has carried both fields since this feature's
|
||
// original commit (confirmed against server source), we just weren't calling it again right
|
||
// after a successful submit. Best-effort: the submission itself already succeeded above, so a
|
||
// failure here shouldn't take that away from the user -- it just means the pre-existing
|
||
// fallback below (this session's own typed draft) keeps covering the display like before.
|
||
getRealnameStatus().then(fresh => {
|
||
if (fresh.masked_name) setMaskedName(fresh.masked_name);
|
||
if (fresh.masked_id_number) setMaskedIdNumber(fresh.masked_id_number);
|
||
}).catch(() => {});
|
||
} catch (err) {
|
||
setError(err.message || '提交失败,请稍后重试');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (status === 'loading' || status === 'load_error') {
|
||
return <div className="auth-page auth-realname-page"><Header title="实名认证" back={authenticated ? 'security' : 'login'} />
|
||
<p className="auth-privacy-note" style={{margin: '2rem 1rem'}}>{status === 'load_error' ? (error || '加载失败,请重试') : '加载中…'}</p>
|
||
</div>;
|
||
}
|
||
|
||
return <div className="auth-page auth-realname-page">
|
||
<Header title="实名认证" back={authenticated ? 'security' : 'login'} />
|
||
<div className="auth-realname-hero"><div><strong>实名认证</strong><span>更加真实,更受欢迎</span></div><Icon>verified_user</Icon></div>
|
||
<main className="auth-realname-content">
|
||
{/* docs/images/实名认证-已实名.png 与 docs/images/实名认证.png 的标题文案完全一致,都是
|
||
"请您使用有效身份证信息认证",已实名状态并没有换一句"认证信息"——原来这里按 status
|
||
分叉出两句不同文案,是从最初的 mock 阶段就带着、从未跟真实原型图核对过的臆造文案,
|
||
一旦账号真的走到 verified 状态就会显示一句参考图里根本不存在的标题。 */}
|
||
<h2>请您使用有效身份证信息认证</h2>
|
||
<form onSubmit={submit}>
|
||
{/* submitRealname's own success response never carries masked_name/masked_id_number (only
|
||
{status, submission_id}) -- submit() above now follows up with a GET /security/realname
|
||
to fetch the server's authoritative masked values right after a successful submission, so
|
||
this local-draft fallback (`name`/`idNumber`, still sitting in state from what was just
|
||
typed) only matters for the brief window before that GET resolves. It still degrades to
|
||
genuinely blank on a fresh page load with no draft in this session and no masked value
|
||
from a prior GET either (e.g. a submission made in an earlier session) -- that residual
|
||
case isn't fixable from here since it depends on the follow-up GET actually having run. */}
|
||
<label><span>姓名</span><input value={editable ? name : (maskedName || name)} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label>
|
||
<label><span>证件号码</span><input value={editable ? idNumber : (maskedIdNumber || idNumber)} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label>
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
<button className="auth-primary" type="submit" disabled={!editable || busy}>
|
||
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
|
||
</button>
|
||
</form>
|
||
<p className="auth-privacy-note"><Icon>info</Icon>我们将获取您的姓名、证件号用于认证,信息会被严格保护</p>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
function RealnameReminder() {
|
||
return <div className="auth-page auth-reminder-demo">
|
||
<div className="auth-demo-chat"><h1>消息</h1><div className="auth-demo-search"><Icon>search</Icon>搜索</div>{['系统消息', '文件助手', '项目协作群', '陈经理'].map((item, i) => <div className="auth-demo-row" key={item}><span>{item[0]}</span><div><b>{item}</b><small>{i ? '最近一条消息内容' : '安全通知与账号动态'}</small></div></div>)}</div>
|
||
<div className="auth-modal-backdrop">
|
||
<div className="auth-modal"><h2>实名认证提醒</h2><p>根据国家法律法规要求,您需先进行实名认证才能使用该项 APP</p><button className="auth-primary" onClick={() => navigate('realname')}>去实名认证</button></div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function IndependentPassword({authenticated, hashParams}) {
|
||
// Only the genuine "haven't set a password yet, setting one now to enable youth mode for the
|
||
// first time" flow should auto-continue into enableYouthMode below -- see IdentityCheck's
|
||
// comment on the same param for why this can't just be inferred from enable_action_token being
|
||
// present.
|
||
const mode = hashParams && hashParams.ym;
|
||
const [pin, setPin] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [toast, setToast] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
// Mirrors server/im_http.go validIMIndependentPassword: 4 digits, not all-same, not a run.
|
||
const invalid = ['0000','1111','2222','3333','4444','5555','6666','7777','8888','9999','0123','1234','2345','3456','4567','5678','6789','9876','8765','7654','6543','5432','4321','3210'];
|
||
const submit = async e => {
|
||
e.preventDefault();
|
||
if (invalid.includes(pin)) { setError('请勿使用连续或重复数字'); return; }
|
||
const stored = sessionStorage.getItem('im-action-token:' + IDENTITY_CHECK_SCENE);
|
||
const actionToken = stored && JSON.parse(stored).action_token;
|
||
if (!actionToken) { setError('身份验证已过期,请返回重新验证'); return; }
|
||
setBusy(true);
|
||
try {
|
||
const result = await setIndependentPassword(pin, 'youth_mode', actionToken);
|
||
sessionStorage.removeItem('im-action-token:' + IDENTITY_CHECK_SCENE);
|
||
setPin('');
|
||
// 设密码只是"首次开启青少年模式"这条路的中间一步,不是终点。服务端在这一步就把下一步
|
||
// 要用的凭证一起发回来了(enable_action_token + next_action:"enable_youth_mode",
|
||
// 见 server/im_http.go:6178-6189)。原来这里把它整个丢掉、只弹一句"设置成功"就停在
|
||
// PIN 页——用户走完短信验证+设密码,青少年模式其实还是关的,得自己摸回设置页再输一遍。
|
||
// 现在拿着这个 token 把最后一步做完。
|
||
// enable_action_token comes back unconditionally on every successful (re)set, regardless of
|
||
// why the caller ended up here (server doesn't know or care) -- only the genuine "first time
|
||
// enabling" flow should spend it. Otherwise this silently flipped youth mode on for someone
|
||
// who was mid-way through disabling it, or who came from the unrelated generic "身份验证"
|
||
// entry with no youth-mode intent at all.
|
||
const enableToken = result && result.enable_action_token;
|
||
if (enableToken && mode === 'enable') {
|
||
try {
|
||
const current = await getYouthMode();
|
||
await enableYouthMode(current.policy_version, enableToken);
|
||
setError(''); setToast('青少年模式已开启');
|
||
window.setTimeout(() => navigate('youth-mode-on'), 600);
|
||
return;
|
||
} catch (enableErr) {
|
||
// 密码确实设成功了,只是自动开启这一步没成,如实说明并把人送回青少年模式页手动开
|
||
setError('独立密码已设置,但自动开启未成功:' + (enableErr.message || '请返回重试'));
|
||
window.setTimeout(() => navigate('youth-mode-off'), 1200);
|
||
return;
|
||
}
|
||
}
|
||
if (mode === 'disable') {
|
||
// enableToken (if any) can't be spent here -- ConsumeActionToken requires an exact
|
||
// action='close_youth_mode' match, and this one is always tagged 'enable_youth_mode'. The
|
||
// user does know the fresh PIN now though, so send them straight back to actually finish
|
||
// disabling instead of leaving them stuck on this screen with nothing left to do.
|
||
setError(''); setToast('独立密码已重置,请使用新密码关闭青少年模式');
|
||
window.setTimeout(() => navigate('youth-mode-verify', {ym: 'disable'}), 800);
|
||
return;
|
||
}
|
||
setError(''); setToast('独立密码设置成功');
|
||
} catch (err) {
|
||
setError(err.message || '设置失败,请返回重新验证身份');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
return <div className="auth-page">
|
||
<Header title="" back={authenticated ? 'security' : 'login'} />
|
||
<main className="auth-pin-content">
|
||
<h1>设置独立密码</h1>
|
||
<div className="auth-pin-wrap">
|
||
<div className="auth-pin-boxes" aria-hidden="true">{[0,1,2,3].map(i => <span key={i}>{pin[i] ? '●' : ''}</span>)}</div>
|
||
<input className="auth-pin-input" type="text" value={pin} onChange={e => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))} inputMode="numeric" autoComplete="off" aria-label="四位独立密码" autoFocus />
|
||
</div>
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
<button className="auth-primary" disabled={pin.length !== 4 || busy} onClick={submit}>{busy ? '提交中…' : '确定'}</button>
|
||
</main>
|
||
<Toast message={toast} onDone={() => setToast('')} />
|
||
</div>;
|
||
}
|
||
|
||
export default function AuthOnboardingView({
|
||
view, hashParams, onTenantResolved, authenticated = false,
|
||
login, loginDisabled, persist, errorText, errorLevel,
|
||
onLoginRequest, onPersistenceChange, onImRegisterRequest, onError
|
||
}) {
|
||
const tenantName = sessionStorage.getItem('im-tenant-name') || '';
|
||
|
||
// errorText/errorLevel is one flat, top-level (tinode-web.jsx) piece of state shared by every
|
||
// view in the whole app -- it's never tied to which sidepanel is showing. Login is the only view
|
||
// here that already clears it on its own (its "切换企业" link calls onError() before navigating),
|
||
// because it was written knowing it can carry a leftover error INTO the tenant screen. Every other
|
||
// transition between these auth views was still missing that: submit a bad invite code on
|
||
// Register (errorText='邀请码错误,请重新输入', view stays 'register', shown correctly), then just
|
||
// click "已有账号?登录" -- Login mounts and, since it renders the exact same errorText/errorLevel
|
||
// props, shows that same registration error as if it were a login failure, with zero user action
|
||
// on this new page. Same the other way: a stale "账号或密码错误" from Login is still sitting there
|
||
// to greet a fresh Register visit. Fixing every call site that navigates between these views
|
||
// (TenantEntry/Login/Register/... several of which don't even receive onError as a prop today)
|
||
// would be a lot of scattered one-off edits for the same bug; clearing it centrally whenever the
|
||
// resolved `view` itself changes covers all of them (button clicks and browser back/forward alike)
|
||
// from the one place they all already funnel through.
|
||
React.useEffect(() => {
|
||
if (onError) onError();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [view]);
|
||
|
||
switch (view) {
|
||
case 'tenant': return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
|
||
case 'login': return <Login tenantName={tenantName} login={login} loginDisabled={loginDisabled} persist={persist}
|
||
errorText={errorText} errorLevel={errorLevel} onLoginRequest={onLoginRequest} onPersistenceChange={onPersistenceChange} onError={onError} />;
|
||
case 'enterprise-register': return <EnterpriseRegister />;
|
||
case 'enterprise-feedback': return <EnterpriseFeedback />;
|
||
case 'register': return <Register errorText={errorText} errorLevel={errorLevel} persist={persist}
|
||
onImRegisterRequest={onImRegisterRequest} onPersistenceChange={onPersistenceChange} />;
|
||
case 'reset': return <PasswordReset />;
|
||
case 'identity-check': return <IdentityCheck authenticated={authenticated} hashParams={hashParams} />;
|
||
case 'realname': return <Realname authenticated={authenticated} />;
|
||
case 'realname-verified': return <Realname authenticated={authenticated} />;
|
||
case 'realname-reminder': return <RealnameReminder />;
|
||
case 'independent-password': return <IndependentPassword authenticated={authenticated} hashParams={hashParams} />;
|
||
default: return <TenantEntry onTenantResolved={onTenantResolved || (() => {})} />;
|
||
}
|
||
}
|