: 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 ?
error_outline{text}
: 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
;
}
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
{/* .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. */}
;
}
// 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 ;
}
// 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
;
}
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
setToast('')} />
;
}
// 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
;
}
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
;
}
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
;
}
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 {})} />;
case 'login': return ;
case 'enterprise-register': return ;
case 'enterprise-feedback': return ;
case 'register': return ;
case 'reset': return ;
case 'identity-check': return ;
case 'realname': return ;
case 'realname-verified': return ;
case 'realname-reminder': return ;
case 'independent-password': return ;
default: return {})} />;
}
}