diff --git a/web/src/features/auth/EnterpriseRegisterPage.tsx b/web/src/features/auth/EnterpriseRegisterPage.tsx index 85a9009e..4c7ccb34 100644 --- a/web/src/features/auth/EnterpriseRegisterPage.tsx +++ b/web/src/features/auth/EnterpriseRegisterPage.tsx @@ -105,7 +105,21 @@ export function EnterpriseRegisterPage() { setFile(e.target.files?.[0] || null)} + onChange={(e) => { + const selected = e.target.files?.[0]; + e.target.value = ''; + if (!selected) return; + // `accept` is only a file-picker hint, not enforcement -- a user can still pick + // an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's + // own screenshot validation rather than silently uploading and waiting on the + // backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check. + if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) { + setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB'); + return; + } + setError(''); + setFile(selected); + }} className="hidden" /> diff --git a/web/src/features/auth/ResetPasswordPage.tsx b/web/src/features/auth/ResetPasswordPage.tsx index 9692d553..66f9954c 100644 --- a/web/src/features/auth/ResetPasswordPage.tsx +++ b/web/src/features/auth/ResetPasswordPage.tsx @@ -100,7 +100,7 @@ export function ResetPasswordPage() {
-

重置密码

+

重置密码

通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。

diff --git a/web/src/features/auth/TenantEntryPage.tsx b/web/src/features/auth/TenantEntryPage.tsx index fa814ce7..a7fafccb 100644 --- a/web/src/features/auth/TenantEntryPage.tsx +++ b/web/src/features/auth/TenantEntryPage.tsx @@ -25,8 +25,14 @@ export function TenantEntryPage() { if (!code.trim() || submitting) return; // Reachable by navigating back here while already logged in (no route guard keeps an // authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down - // that live session with no warning at all. - if (myUserId && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return; + // that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory + // Zustand value that resets to null on any fresh mount of this module (a page reload while + // sitting on /tenant, or opening this URL directly in a new tab) even though the session token + // is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading + // RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it + // never mounts for this route. Checking the token directly closes that gap. + const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token'); + if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return; setSubmitting(true); setError(''); try { @@ -88,6 +94,7 @@ export function TenantEntryPage() { value={code} onChange={(e) => setCode(e.target.value)} placeholder="请输入企业码" + maxLength={64} className="w-full bg-transparent text-[15px] outline-none" /> diff --git a/web/src/features/auth/components/VerificationCodeButton.tsx b/web/src/features/auth/components/VerificationCodeButton.tsx index f0f9619c..02f99dcd 100644 --- a/web/src/features/auth/components/VerificationCodeButton.tsx +++ b/web/src/features/auth/components/VerificationCodeButton.tsx @@ -47,12 +47,20 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V const click = async () => { if (busy || seconds > 0 || !PHONE_RE.test(phone)) return; + // The phone number this request is actually FOR -- captured now, not read again after the + // `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in + // flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever + // number is now showing, with nothing on screen indicating the mismatch -- the eventual + // verification would silently operate on an account the user never intended. + const requestedPhone = phone; setBusy(true); try { - const data = await requestVerificationCode(scene, phone); + const data = await requestVerificationCode(scene, requestedPhone); + if (requestedPhone !== phone) return; onChallenge(data.challenge_id); tick(data.resend_after_seconds || 60); } catch (err) { + if (requestedPhone !== phone) return; onError(err instanceof ImApiError ? err.message : '获取验证码失败'); // Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it. const details = err instanceof ImApiError ? (err.details as Record | undefined) : undefined; diff --git a/web/src/features/messages/ChatListColumn.tsx b/web/src/features/messages/ChatListColumn.tsx index 7269881b..241c2a26 100644 --- a/web/src/features/messages/ChatListColumn.tsx +++ b/web/src/features/messages/ChatListColumn.tsx @@ -165,7 +165,7 @@ export function ChatListColumn() { {/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right after the name (e.g. "项目协作群 28人"); P2P rows never have one. */} {typeof memberCount === 'number' ? ( - {memberCount}人 + {memberCount}人 ) : null} {preview} diff --git a/web/src/features/messages/MessageBubble.tsx b/web/src/features/messages/MessageBubble.tsx index 1d18c2e6..0cdf581e 100644 --- a/web/src/features/messages/MessageBubble.tsx +++ b/web/src/features/messages/MessageBubble.tsx @@ -161,7 +161,7 @@ export function MessageRow({ -- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
- {failed ? : null} + {failed ? : null}
{failed ? ( - ) : ( diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx index 909fd705..b8e840f0 100644 --- a/web/src/features/messages/MessagesLayout.tsx +++ b/web/src/features/messages/MessagesLayout.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Outlet, useLocation, useParams } from 'react-router-dom'; import { ChatListColumn } from '@/features/messages/ChatListColumn'; @@ -8,6 +9,7 @@ import { HistorySearchModal } from '@/features/messages/HistorySearchModal'; import { OfflineBanner } from '@/features/messages/OfflineBanner'; import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn'; import type { MediaType } from '@/lib/api/messages'; +import { getMe } from '@/lib/api/profile'; import { useMediaQuery } from '@/lib/hooks'; import { useChatList, useTinode } from '@/lib/tinode/hooks'; @@ -86,7 +88,14 @@ export function MessagesLayout() { const showListOnNarrow = !topicId && location.pathname !== '/messages/system'; const contacts = useChatList(); const tinode = useTinode(); - const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || ''; + // `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the + // lookup below always misses for it -- same gap ConversationView.tsx's own header already had and + // fixed the same way, just computed independently here for this panel's title/detail-column + // props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has + // already mounted it, not a second request. + const isSelf = topicId === 'slf'; + const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf }); + const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || ''; const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType(); const detailVisible = isDocked ? detailPanelOpen : detailOpen; diff --git a/web/src/features/messages/VoiceRecorder.tsx b/web/src/features/messages/VoiceRecorder.tsx index 5440b843..78012876 100644 --- a/web/src/features/messages/VoiceRecorder.tsx +++ b/web/src/features/messages/VoiceRecorder.tsx @@ -123,8 +123,8 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) { return (
- - + + 录音中 {formatSeconds(seconds)} diff --git a/web/src/features/settings/SettingsLayout.tsx b/web/src/features/settings/SettingsLayout.tsx index 6bc9eb96..705a5e51 100644 --- a/web/src/features/settings/SettingsLayout.tsx +++ b/web/src/features/settings/SettingsLayout.tsx @@ -5,6 +5,7 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Avatar } from '@/app/Avatar'; import { Icon } from '@/app/Icon'; +import { logout as revokeServerSession } from '@/lib/api/auth'; import { getMe } from '@/lib/api/profile'; import { resetTinode } from '@/lib/tinode/connection'; import { requestConfirm } from '@/stores/confirmDialog'; @@ -35,12 +36,7 @@ const MOBILE_SHORTCUTS = [ { key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' }, ]; -/** - * README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely - * client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout - * endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which - * never calls a server logout endpoint at all, just clears local token/session and disconnects. - */ +/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */ export function SettingsLayout() { const location = useLocation(); const navigate = useNavigate(); @@ -56,6 +52,11 @@ export function SettingsLayout() { const logout = async () => { if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return; setLoggingOut(true); + // docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近 + // 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空 + // sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条 + // 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。 + await revokeServerSession().catch(() => {}); resetTinode(); sessionStorage.removeItem('im-session-token'); setMyUserId(null); diff --git a/web/src/index.css b/web/src/index.css index 292e021f..9f03bf2e 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -27,6 +27,8 @@ white-space: nowrap; word-wrap: normal; direction: ltr; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; } } diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index d86a8933..42819778 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -1,5 +1,16 @@ import { imPost, imRequest } from './client'; +/** + * docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/ + * 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个 + * 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。 + * 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次 + * 调用失败而跳过后续的本地清理。 + */ +export function logout() { + return imPost('/auth/logout'); +} + export interface VerificationCodeResponse { challenge_id: string; resend_after_seconds: number; diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 92762adb..d6609097 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -7,6 +7,7 @@ // is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was // a real, repeatedly-rediscovered bug in the old codebase). import { API_KEY } from '@/lib/config'; +import { getKickedInfo } from '@/lib/tinode/connection'; import { useSessionStore } from '@/stores/session'; const DEFAULT_API_BASE = '/v1/im'; @@ -29,7 +30,7 @@ export class ImApiError extends Error { const REASON_MESSAGES: Record string | null)> = { API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试', METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试', - TENANT_UNAVAILABLE: '企业码不存在或已停用', + TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试', TENANT_MISMATCH: '登录状态异常,请重新进入企业码', AUTH_FAILED: '账号或密码错误', TOKEN_EXPIRED: '登录已过期,请重新登录', @@ -207,17 +208,22 @@ function forceReauth() { if (reauthTriggered) return; sessionStorage.removeItem('im-session-token'); useSessionStore.getState().setMyUserId(null); - // The latch only needs to survive until the hard reload below actually happens -- that reload is - // what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the - // latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background - // request 401-ing while already sitting on `/login` (reachable via an in-SPA `<Navigate>`, which - // never reloads) burned the latch with no reload to reset it -- silently disabling this function - // for the rest of that tab's life, including a genuine expiry after the user logs back in on the - // same SPA instance. - if (typeof window !== 'undefined' && window.location.pathname !== '/login') { - reauthTriggered = true; + if (typeof window === 'undefined' || window.location.pathname === '/login') return; + reauthTriggered = true; + // An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by + // the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever + // request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to + // network timing, not something this code controls). Without this, a REST 401 that happens to + // land first bounces straight to /login with zero explanation, silently pre-empting the whole + // point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets + // that WS message (usually already in flight over an open connection, typically faster than a + // fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer + // to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved, + // by far the common case) just navigate ~200ms later than before -- not perceptible. + window.setTimeout(() => { + if (getKickedInfo()) return; window.location.href = '/login'; - } + }, 200); } const idem = () => ({ 'Idempotency-Key': requestId() }); diff --git a/web/src/lib/messages/actions.ts b/web/src/lib/messages/actions.ts index d8e3a5a6..2473dfb0 100644 --- a/web/src/lib/messages/actions.ts +++ b/web/src/lib/messages/actions.ts @@ -1,7 +1,7 @@ import type { StoredMessage } from 'tinode-sdk'; import type { MessagePolicy } from '@/lib/api/bootstrap'; -import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch'; +import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch'; /** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's * own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */ @@ -33,7 +33,13 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu */ export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] { if (isServerDeleted(msg) || isRecalled(msg)) return []; - const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward']; + // 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on + // contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/ + // forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an + // object it doesn't recognize, producing empty/malformed output rather than an error a user would + // notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types. + const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null; + const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward']; // Defer to the server's own supported_actions list when it's present, so a server-side policy // change (recall disabled tenant-wide, say) doesn't leave the client offering an action that // will just 400 when tapped -- the time-window check below still applies on top of this. diff --git a/web/src/lib/tinode/calls.ts b/web/src/lib/tinode/calls.ts index 8cfdf21e..bc790721 100644 --- a/web/src/lib/tinode/calls.ts +++ b/web/src/lib/tinode/calls.ts @@ -66,14 +66,35 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>; } +/** + * `Topic#videoCall` sends a `{note}` packet, which (unlike `{pub}`) carries no `id` field -- read + * the SDK source: with no id to route a response through, a disconnected socket makes the + * underlying `sendText()` throw SYNCHRONOUSLY rather than reject a promise. None of this module's + * callers in `stores/calls.ts` await or `.catch()` these calls (they're fire-and-forget signals), + * so an uncaught synchronous throw here doesn't just fail silently -- it aborts whatever code was + * running right after the call site. Concretely: `hangUp()`'s 60s ring-timeout callback calls this, + * and if the connection happens to be down at that exact moment (e.g. `resetTinode()` ran because + * the account was evicted mid-call), the exception skips every line after it in that function, + * including the local Agora `leaveCall()` cleanup and the UI's own end-of-call state transition -- + * the call window would stay stuck open with a live camera/mic track. These three are the only + * `note`-based signals in the app; wrapping here (once) covers every call site in one place. + */ +function sendNote(topic: Topic, event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record) { + try { + return topic.videoCall(event, seq, payload); + } catch { + return undefined; + } +} + export function sendCallRinging(topic: Topic, seq: number, callId: string) { - return topic.videoCall('ringing', seq, { callId }); + return sendNote(topic, 'ringing', seq, { callId }); } export function sendCallAccept(topic: Topic, seq: number, callId: string) { - return topic.videoCall('accept', seq, { callId }); + return sendNote(topic, 'accept', seq, { callId }); } export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) { - return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId }); + return sendNote(topic, 'hang-up', seq, reason ? { callId, reason } : { callId }); }