修复登录页浏览器后退卡死、已认证态下登录/注册的凭据校验绕过、系统消息分页丢失等第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>
This commit is contained in:
Your Name
2026-09-05 18:36:03 +08:00
co-authored by Claude Sonnet 5
parent 8b51fd3eb8
commit 47148aa75d
9 changed files with 495 additions and 399 deletions
+14
View File
@@ -7589,6 +7589,20 @@ div.toast.show {
box-shadow: 0 0.2rem 0.8rem rgba(37,49,65,0.04); box-shadow: 0 0.2rem 0.8rem rgba(37,49,65,0.04);
} }
.system-notification-load-more {
display: block;
width: 100%;
margin: 0;
padding: 0.7rem;
border: 1px solid #eceef1;
border-radius: 0.5rem;
box-shadow: none;
color: #567;
background: #fff;
font-size: 0.85rem;
text-transform: none;
}
.system-notification-list article.read .system-notification-card { .system-notification-list article.read .system-notification-card {
opacity: 0.82; opacity: 0.82;
} }
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@ export const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');
export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K'; export const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';
// The array of possible hosts to connect to. // The array of possible hosts to connect to.
export const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'}; export const KNOWN_HOSTS = {hosted: 'web.118im.com', local: 'localhost:6060'};
// Default host name and port to connect to. // Default host name and port to connect to.
export const DEFAULT_HOST = KNOWN_HOSTS.hosted; export const DEFAULT_HOST = KNOWN_HOSTS.hosted;
+9 -1
View File
@@ -4,9 +4,17 @@ import { KNOWN_HOSTS, DEFAULT_HOST } from '../config.js';
export function detectServerAddress() { export function detectServerAddress() {
let host = DEFAULT_HOST; let host = DEFAULT_HOST;
if (typeof window.location == 'object') { if (typeof window.location == 'object') {
if (window.location.protocol == 'file:' || window.location.hostname == 'localhost') { if (window.location.protocol == 'file:') {
// No real page origin to derive a host/port from at all (e.g. a file:// build opened
// directly) -- this is the one case KNOWN_HOSTS.local's hardcoded port is actually needed.
host = KNOWN_HOSTS.local; host = KNOWN_HOSTS.local;
} else if (window.location.hostname) { } else if (window.location.hostname) {
// Was `hostname == 'localhost'` on its own branch returning the hardcoded KNOWN_HOSTS.local
// ('localhost:6060') unconditionally -- any local/self-hosted deployment or dev proxy not on
// port 6060 (this project's own dev-proxy runs on 8093) got a wrong host and every "no
// cached tenant/host info yet" connection attempt (e.g. right after clearing storage) failed
// outright before a later reconnect could recover with the right address. Same-origin
// same-port assumption already used for every non-localhost host applies here too.
host = window.location.hostname + (window.location.port ? ':' + window.location.port : ''); host = window.location.hostname + (window.location.port ? ':' + window.location.port : '');
} }
} }
+15 -8
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import { import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile, resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset, requestVerificationCode, verifyRegisterCode, verifyPasswordResetCode, confirmPasswordReset,
@@ -24,8 +25,8 @@ function Icon({children, className = ''}) {
function AppMark({tenantName}) { function AppMark({tenantName}) {
return <div className="auth-brand"> return <div className="auth-brand">
<div className="auth-logo"><span>J</span></div> <div className="auth-logo"><span>{BASE_APP_NAME[0]}</span></div>
<h1>{tenantName || '即时通讯'}</h1> <h1>{tenantName || BASE_APP_NAME}</h1>
</div>; </div>;
} }
@@ -59,7 +60,7 @@ function PasswordField(props) {
aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />; aria-label={visible ? '隐藏密码' : '显示密码'}><Icon>{visible ? 'visibility' : 'visibility_off'}</Icon></button>} />;
} }
function Agreement({checked, onChange, prefix = '同意捷信密使'}) { function Agreement({checked, onChange, prefix = `同意${BASE_APP_NAME}`}) {
return <label className="auth-agreement"> return <label className="auth-agreement">
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} /> <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> <span>{prefix} <a href="#agreement" onClick={e => e.preventDefault()}>《用户协议》</a> 和 <a href="#privacy" onClick={e => e.preventDefault()}>《隐私政策》</a></span>
@@ -456,7 +457,7 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
return <div className="auth-landing auth-register-page"> return <div className="auth-landing auth-register-page">
<form className="auth-landing-form auth-register-form" onSubmit={submit}> <form className="auth-landing-form auth-register-form" onSubmit={submit}>
<AppMark tenantName={sessionStorage.getItem('im-tenant-name') || '讯工捷迅'} /> <AppMark tenantName={sessionStorage.getItem('im-tenant-name') || BASE_APP_NAME} />
<FormAlert text={errorLevel === 'err' ? errorText : ''} /> <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)})} <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" /> placeholder="请输入用户名" error={errors.username} autoComplete="username" />
@@ -475,9 +476,9 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
<button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}> <button className="auth-primary" type="submit" disabled={(!form.username && !form.phone) || !form.password || !agreed || submitting}>
{submitting ? '注册中…' : '注册'} {submitting ? '注册中…' : '注册'}
</button> </button>
<p className="auth-switch">已有讯工捷迅账号? <button type="button" onClick={() => navigate('login')}>登录</button></p> <p className="auth-switch">已有{BASE_APP_NAME}账号? <button type="button" onClick={() => navigate('login')}>登录</button></p>
</form> </form>
<Agreement checked={agreed} onChange={setAgreed} prefix="注册即认同捷讯" /> <Agreement checked={agreed} onChange={setAgreed} prefix={`注册即认同${BASE_APP_NAME}`} />
</div>; </div>;
} }
@@ -676,8 +677,14 @@ function Realname({authenticated = false}) {
<main className="auth-realname-content"> <main className="auth-realname-content">
<h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2> <h2>{status === 'verified' ? '认证信息' : '请您使用有效身份证信息认证'}</h2>
<form onSubmit={submit}> <form onSubmit={submit}>
<label><span>姓名</span><input value={editable ? name : maskedName} onChange={e => setName(e.target.value)} readOnly={!editable} placeholder="请输入真实姓名" /></label> {/* submitRealname's success response never actually carries masked_name/masked_id_number
<label><span>证件号码</span><input value={editable ? idNumber : maskedIdNumber} onChange={e => setIdNumber(e.target.value)} readOnly={!editable} placeholder="请输入证件号码" /></label> (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} {error ? <div className="auth-field-error">{error}</div> : null}
<button className="auth-primary" type="submit" disabled={!editable || busy}> <button className="auth-primary" type="submit" disabled={!editable || busy}>
{status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'} {status === 'verified' ? '已实名' : status === 'pending' ? '审核中' : busy ? '提交中…' : '提交'}
+18 -3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Tinode } from 'tinode-sdk'; import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js'; import HashNavigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js'; import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js'; import { makeImageUrl } from '../lib/blob-helpers.js';
import { useCachedData } from '../lib/use-cached-data.js'; import { useCachedData } from '../lib/use-cached-data.js';
@@ -417,16 +418,29 @@ function systemNotificationText(item) {
function SystemMessages({authenticated}) { function SystemMessages({authenticated}) {
const [items, setItems] = React.useState([]); const [items, setItems] = React.useState([]);
const [cursor, setCursor] = React.useState('');
const [loadingMore, setLoadingMore] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(''); const [error, setError] = React.useState('');
const [toast, setToast] = React.useState(''); const [toast, setToast] = React.useState('');
React.useEffect(() => { React.useEffect(() => {
getSystemNotifications().then(data => setItems(data.items || [])) getSystemNotifications().then(data => { setItems(data.items || []); setCursor(data.next_cursor || ''); })
.catch(err => setError(err.message || '加载失败')) .catch(err => setError(err.message || '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
// GET /system-notifications does return next_cursor whenever there's more (real accounts with
// 50 unread saw it truncate silently at 20, with no way to reach or ever mark the rest read).
const loadMore = () => {
if (loadingMore || !cursor) return;
setLoadingMore(true);
getSystemNotifications(cursor).then(data => {
setItems(list => list.concat(data.items || []));
setCursor(data.next_cursor || '');
}).catch(() => {}).finally(() => setLoadingMore(false));
};
const markRead = item => { const markRead = item => {
if (item.read_at) return; if (item.read_at) return;
setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i)); setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));
@@ -459,14 +473,15 @@ function SystemMessages({authenticated}) {
猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */} 猜的,不是照真实响应写的)——导致"查看新朋友"按钮对这条最常见的通知永远不出现。 */}
{item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ? {item.action && typeof item.action.topic === 'string' && item.action.topic.startsWith('grp') ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> : <button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('group-settings', item.action.topic); }}>查看群聊 <Icon>chevron_right</Icon></button> :
item.event_type && item.event_type.startsWith('friend.request') ? item.event_type === 'friend.request.created' ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> : <button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate('friend-requests'); }}>查看新朋友 <Icon>chevron_right</Icon></button> :
item.action && (item.action.device || item.action.ip) ? item.action && (item.action.device || item.action.ip) ?
<button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> : <button type="button" className="system-security-link" onClick={e => { e.stopPropagation(); navigate(authenticated ? 'security' : 'identity-check'); }}>前往安全设置 <Icon>chevron_right</Icon></button> :
null} null}
<footer>保持警惕<br />即时通讯安全团队</footer> <footer>保持警惕<br />{BASE_APP_NAME}安全团队</footer>
</div> </div>
</article>)} </article>)}
{cursor ? <button type="button" className="system-notification-load-more" disabled={loadingMore} onClick={loadMore}>{loadingMore ? '加载中…' : '加载更多'}</button> : null}
</main> </main>
<Toast message={toast} onDone={() => setToast('')} /> <Toast message={toast} onDone={() => setToast('')} />
</div>; </div>;
+28 -2
View File
@@ -616,10 +616,16 @@ class TinodeWeb extends React.Component {
} }
initFCMessaging() { initFCMessaging() {
const {formatMessage, locale} = this.props.intl; const {locale} = this.props.intl;
const onError = (msg, err) => { const onError = (msg, err) => {
console.error(msg, err); console.error(msg, err);
this.handleError(formatMessage(messages.push_init_failed), 'err'); // Was routed through the same handleError()/errorText the login/register form's own alert
// renders from -- push notifications are best-effort (the app works fully without them), but
// sharing that channel meant this could cover up a real, concurrent login/register error, or
// (since initFCMessaging is called right as a login request starts) just surface on its own,
// with no login error at all, on a form the user hasn't touched yet. Silently degrading
// (console log + the state resets below, which already disable the desktop-alerts setting)
// is enough; failing to enable push is not something that needs to interrupt the auth flow.
this.setState({firebaseToken: null}); this.setState({firebaseToken: null});
LocalStorageUtil.updateObject('settings', {desktopAlerts: false}); LocalStorageUtil.updateObject('settings', {desktopAlerts: false});
} }
@@ -750,6 +756,20 @@ class TinodeWeb extends React.Component {
// Handle for hash navigation (hashchange) event: update state. // Handle for hash navigation (hashchange) event: update state.
handleHashRoute() { handleHashRoute() {
const hash = HashNavigation.parseUrlHash(window.location.hash); const hash = HashNavigation.parseUrlHash(window.location.hash);
// Browser back navigation after a successful login re-renders one of the pre-login views
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
// hash routing and the WS session's lifecycle are independent, so nothing else here would
// otherwise catch this. Landing back on a login/register form while already logged in used to
// let a stale, disabled submit button sit there permanently (loginDisabled is only ever reset
// on a real login attempt's own success/failure, and none happens here), and a form actually
// submitted from this state hit doLogin's/handleNewAccountRequest's own "already authenticated"
// short-circuit -- silently skipping credential validation for login, or surfacing a raw
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
// that code runs removes the only way to reach it.
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
HashNavigation.navigateTo('');
return;
}
// Start with panel parameters. // Start with panel parameters.
const newState = { const newState = {
infoPanel: hash.params.info, infoPanel: hash.params.info,
@@ -1073,6 +1093,12 @@ class TinodeWeb extends React.Component {
me.onSubsUpdated = this.tnMeSubsUpdated; me.onSubsUpdated = this.tnMeSubsUpdated;
this.setState({ this.setState({
connected: true, connected: true,
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
// since it navigates away immediately -- until the same Login form could be reached again
// afterwards (browser back) with this still stuck true, permanently disabling the submit
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
// it here too means this can't get stuck regardless of how the form ends up rendered again.
loginDisabled: false,
credMethod: undefined, credMethod: undefined,
credCode: undefined, credCode: undefined,
credToken: undefined, credToken: undefined,
+408 -382
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
File diff suppressed because one or more lines are too long