Files
im_webapp/src/views/auth-onboarding-view.jsx
T
Your NameandClaude Sonnet 5 47148aa75d 修复登录页浏览器后退卡死、已认证态下登录/注册的凭据校验绕过、系统消息分页丢失等第1-2章问题
- 最主要的一处:登录成功后点浏览器"后退",会回到一个仍然认为"已登录"的底层连接、但重新挂载出来
  的登录表单——按钮永久卡在禁用的"登录中…"(loginDisabled只有失败分支会重置,成功分支从来没有,
  因为正常路径下这个页面早就导航走了,没人会看到);如果不管这个直接切换企业码后再次提交,
  doLogin()/handleNewAccountRequest()开头的"已认证就直接放行"逻辑会让登录悄悄跳过凭据校验,
  或者让注册请求收到后端原始英文错误"already authenticated (409)"。根治方式是在
  handleHashRoute加一道门:hash指向登录/注册等纯"未登录态"视图、但连接其实已认证时,直接重定向
  离开,不让这类表单有机会被重新渲染出来——已用真实浏览器验证4个入口(login/register/tenant/
  reset/cred)全部正确重定向,且确认不影响真正的登录后安全类页面(如identity-check)。
  handleLoginSuccessful也顺手补了loginDisabled:false兜底。
- 系统消息列表:后端`GET /system-notifications`明明返回`next_cursor`表示还有更多,前端从来没用过,
  未读50+条永远只能看到最近20条、永远没法真正清零。补了"加载更多"分页——已用真实账号(60+条历史
  通知)验证,点击后正确追加下一页、游标耗尽后正确不再显示按钮。
- 已处理(通过/拒绝)的好友申请通知,原来只要event_type以"friend.request"开头就显示"查看新朋友"
  按钮——不区分"待处理"和"已是历史结果",点了跳到待处理列表也找不到对应项。改成只在
  `friend.request.created`时显示,已用真实账号里同时存在created/accepted/rejected三种通知
  验证行为符合预期。
- 注册页四处不统一的占位品牌名(讯工捷迅/捷信密使/捷讯/即时通讯,没有一个是真实品牌名)与
  系统消息页脚"即时通讯安全团队",统一改用BASE_APP_NAME("MeetingIM")。
- config.js的KNOWN_HOSTS.hosted从web.tinode.co改为web.118im.com(这是WS连接失败时的最终
  兜底地址,指向无关第三方域名不只是品牌问题,是真实会连错服务器);host-name.js的
  detectServerAddress()原来只要hostname是localhost就无条件返回硬编码的localhost:6060,
  完全不管页面实际监听的端口——本项目自己的开发代理跑在8093,清缓存后首次连接必然先撞一次
  死连接,改成和非localhost分支一样用页面自己的真实端口。
- 实名认证提交成功后,姓名/证件号输入框原来会变成空白(后端成功响应不含masked_name/
  masked_id_number,组件切到只读态时又没有回退到用户自己刚输入的草稿值)——改成没有脱敏字段时
  回退显示用户本次会话里刚输入的原始值,不是留白。
- Firebase推送通知初始化失败原来复用登录/注册表单的错误提示通道,可能盖住一个同时发生的真实
  登录错误,或在用户还没操作登录表单时就先弹出一条无关提示——推送本身是锦上添花功能,改成只
  console记录+静默关闭开关,不再抢占登录页的错误展示位。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 18:36:03 +08:00

808 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import HashNavigation 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 `HashNavigation.navigateTo(view)` did.
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([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 (!HashNavigation.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" icon="qr_code_scanner" error={errors.creditCode}
onIconClick={() => setToast('扫码识别暂不可用,请手动输入')} />
<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, onImRegisterRequest}) {
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;
// 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" />
<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('');
} 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">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<form onSubmit={submit}>
{/* submitRealname's success response never actually carries masked_name/masked_id_number
(only {status, submission_id}) -- falling back to what was just typed (still sitting in
`name`/`idNumber` local state) means a fresh submission at least shows the user what
they submitted, instead of switching straight from their typed input to two blank
fields the instant `editable` flips false. Only degrades to genuinely blank on a fresh
page load with no draft in this session, which is the pre-existing, unavoidable case. */}
<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') || '';
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} onImRegisterRequest={onImRegisterRequest} />;
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 || (() => {})} />;
}
}