- 最主要的一处:登录成功后点浏览器"后退",会回到一个仍然认为"已登录"的底层连接、但重新挂载出来
的登录表单——按钮永久卡在禁用的"登录中…"(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>
808 lines
44 KiB
React
808 lines
44 KiB
React
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 || (() => {})} />;
|
||
}
|
||
}
|