diff --git a/web/src/App.tsx b/web/src/App.tsx index fb14d7d9..898196b7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'; import { AppShell } from '@/app/AppShell'; +import { RequireAuth } from '@/app/RequireAuth'; import { SectionPlaceholder } from '@/app/SectionPlaceholder'; +import { LoginPage } from '@/features/auth/LoginPage'; +import { TenantEntryPage } from '@/features/auth/TenantEntryPage'; import { ConversationView } from '@/features/messages/ConversationView'; import { EmptyMain } from '@/features/messages/EmptyMain'; import { MessagesLayout } from '@/features/messages/MessagesLayout'; @@ -10,23 +13,30 @@ import { MessagesLayout } from '@/features/messages/MessagesLayout'; const queryClient = new QueryClient(); const router = createBrowserRouter([ + { path: 'tenant', element: }, + { path: 'login', element: }, { - element: , + element: , children: [ - { index: true, element: }, { - path: 'messages', - element: , + element: , children: [ - { index: true, element: }, - { path: ':topicId', element: }, + { index: true, element: }, + { + path: 'messages', + element: , + children: [ + { index: true, element: }, + { path: ':topicId', element: }, + ], + }, + { path: 'contacts', element: }, + { path: 'favorites', element: }, + { path: 'files', element: }, + { path: 'calls', element: }, + { path: 'settings', element: }, ], }, - { path: 'contacts', element: }, - { path: 'favorites', element: }, - { path: 'files', element: }, - { path: 'calls', element: }, - { path: 'settings', element: }, ], }, ]); diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx new file mode 100644 index 00000000..16a7b4e1 --- /dev/null +++ b/web/src/app/RequireAuth.tsx @@ -0,0 +1,13 @@ +import { Navigate, Outlet } from 'react-router-dom'; + +import { useSessionStore } from '@/stores/session'; + +/** Gate for everything inside AppShell: needs a resolved tenant and an authenticated user. */ +export function RequireAuth() { + const tenant = useSessionStore((s) => s.tenant); + const myUserId = useSessionStore((s) => s.myUserId); + + if (!tenant) return ; + if (!myUserId) return ; + return ; +} diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx new file mode 100644 index 00000000..312f83b1 --- /dev/null +++ b/web/src/features/auth/LoginPage.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; + +import { Navigate, useNavigate } from 'react-router-dom'; + +import { connect } from '@/lib/tinode/connection'; +import { useTinode } from '@/lib/tinode/hooks'; +import { useSessionStore } from '@/stores/session'; + +/** + * ctrl.code/text -> Chinese message. Ported from the old app's loginErrorMessage() -- the SDK's + * own login promise rejects with an Error whose `.code` is the ctrl code (401/404/500/...), not + * with an ImApiError, since this is the native WS protocol, not the /v1/im REST layer. + */ +function loginErrorMessage(err: unknown): string { + const code = (err as { code?: number })?.code; + if (code === 401) return '账号或密码错误'; + if (code === 404) return '账号不存在'; + if (code && code >= 500) return '服务器繁忙,请稍后重试'; + return (err as Error)?.message || '登录失败,请重试'; +} + +export function LoginPage() { + const navigate = useNavigate(); + const tenant = useSessionStore((s) => s.tenant); + const setMyUserId = useSessionStore((s) => s.setMyUserId); + const tinode = useTinode(); + const [login, setLogin] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + if (!tenant) { + // No tenant resolved yet this session -- can't build a valid {hi} packet without it. + return ; + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!login.trim() || !password || submitting) return; + setSubmitting(true); + setError(''); + try { + if (!tinode.isConnected()) { + await connect(); + } + const { ctrl } = await tinode.loginBasic(login.trim(), password); + if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { + // Account created but needs credential confirmation (email/SMS code) -- full flow lands + // with the register/credential-confirm screens later in Phase 1. + setError('账号需要先完成验证,此流程正在实现中'); + return; + } + const authToken = tinode.getAuthToken(); + sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); + setMyUserId(tinode.getAuthenticatedUserID() ?? null); + navigate('/messages'); + } catch (err) { + setError(loginErrorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+ {tenant.name} +

登录

+ {error ? ( +
{error}
+ ) : null} + setLogin(e.target.value)} + placeholder="用户名或手机号" + className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring" + /> + setPassword(e.target.value)} + placeholder="密码" + className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring" + /> + +
+
+ ); +} diff --git a/web/src/features/auth/TenantEntryPage.tsx b/web/src/features/auth/TenantEntryPage.tsx new file mode 100644 index 00000000..22e85dae --- /dev/null +++ b/web/src/features/auth/TenantEntryPage.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; + +import { useNavigate } from 'react-router-dom'; + +import { ImApiError } from '@/lib/api/client'; +import { resolveTenant } from '@/lib/api/tenant'; +import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session'; + +export function TenantEntryPage() { + const navigate = useNavigate(); + const setTenant = useSessionStore((s) => s.setTenant); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!code.trim() || submitting) return; + setSubmitting(true); + setError(''); + try { + const res = await resolveTenant(code.trim()); + const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket); + setTenant({ code: res.tenant.code, name: res.tenant.name, endpoints: res.endpoints, host, secure }); + navigate('/login'); + } catch (err) { + setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试'); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+
+ M +
+ MeetingIM +
+ {error ? ( +
{error}
+ ) : null} + + +
+
+ ); +} diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 00000000..cd3be29c --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,211 @@ +// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS +// protocol. Ported from the previous im_webapp's src/lib/im-api.js -- the header names, the +// reason-code translation table, and the batch-endpoint "always 200, real result in items[]" +// quirk are all hard-won facts about this exact backend (im_team/im_server, server/im_http.go), +// verified against a live deployment over months of integration work. They are not guesses and +// should not be "cleaned up" back to what the upstream Tinode docs say (this deployment's header +// 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 { useSessionStore } from '@/stores/session'; + +const DEFAULT_API_BASE = '/v1/im'; +const DEFAULT_FILE_BASE = '/v0/file'; + +export class ImApiError extends Error { + reason?: string; + status?: number; + details?: unknown; + + constructor(message: string, reason?: string, status?: number, details?: unknown) { + super(message); + this.reason = reason; + this.status = status; + this.details = details; + } +} + +/** docs/IM-前后端对接文档.md §12.4's full reason list -- see client.ts's header comment. */ +const REASON_MESSAGES: Record string | null)> = { + API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试', + METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试', + TENANT_UNAVAILABLE: '企业码不存在或已停用', + TENANT_MISMATCH: '登录状态异常,请重新进入企业码', + AUTH_FAILED: '账号或密码错误', + TOKEN_EXPIRED: '登录已过期,请重新登录', + ACCOUNT_DISABLED: '账号已被禁用', + RATE_LIMITED: '操作过于频繁,请稍后再试', + VALIDATION_FAILED: '提交的内容不符合要求', + IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试', + RESOURCE_NOT_FOUND: '内容不存在或已被删除', + PERMISSION_DENIED: '没有权限执行此操作', + NOT_FOUND: '内容不存在或已被删除', + VERSION_CONFLICT: '内容已被修改,请刷新后重试', + MALFORMED_REQUEST: '请求内容有误,请稍后重试', + AUTH_REQUIRED: '请先登录', + ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作', + SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试', + IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试', + INTERNAL_ERROR: '服务器繁忙,请稍后重试', + UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型', + CHALLENGE_EXPIRED: '验证码已过期,请重新获取', + SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试', + PASSWORD_POLICY_VIOLATION: '密码不符合安全要求', + VERIFICATION_RATE_LIMITED: (details) => + isRetryAfter(details) ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试', + ALREADY_GROUP_MEMBER: '对方已经是群成员', + DESCRIPTION_REQUIRED: '请填写简介内容', + DESCRIPTION_TOO_LONG: '简介内容过长', + GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限', + INVALID_CREDIT_CODE: '统一社会信用代码格式不正确', + INVALID_EMAIL: '邮箱格式不正确', + MAILBOX_UNAVAILABLE: '总经理信箱暂不可用', + TOO_MANY_FILES: '文件数量超出限制', + FRIEND_RELATION_REQUIRED: '需要先添加对方为好友', + MESSAGE_NOT_ALLOWED: '当前状态无法发送消息', + GROUP_MUTED: '你已被禁言,无法发言', + GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限', + TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限', + TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限', + FILE_TOO_LARGE: '文件大小超出限制', + FILE_FORBIDDEN: '没有权限访问此文件', + FILE_UNAVAILABLE: '文件暂不可用', + STORAGE_QUOTA_EXCEEDED: '存储空间已达上限', + REALNAME_REQUIRED: '需要先完成实名认证', + YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能', + RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认', + RISK_ACTION_BLOCKED: '该操作已被安全策略拦截', + // Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS + // verification-code verify) -- keep generic enough to be accurate for both. + CODE_INVALID: '验证码或独立密码不正确', + TOO_MANY_ATTEMPTS: (details) => + isRetryAfter(details) ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试', +}; + +function isRetryAfter(details: unknown): details is { retry_after_seconds: number } { + return !!details && typeof details === 'object' && 'retry_after_seconds' in details; +} + +export function translateReason(reason: string | undefined, details?: unknown): string | null { + if (!reason) return null; + const entry = REASON_MESSAGES[reason]; + return (typeof entry === 'function' ? entry(details) : entry) || null; +} + +function requestId(): string { + return typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; +} + +function apiBase(): string { + const endpoints = useSessionStore.getState().tenant?.endpoints; + return (endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, ''); +} + +export function fileBase(): string { + const endpoints = useSessionStore.getState().tenant?.endpoints; + return (endpoints?.file || DEFAULT_FILE_BASE).replace(/\/$/, ''); +} + +export function currentTenantCode(): string { + return useSessionStore.getState().tenant?.code || 'default'; +} + +/** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */ +function authToken(): string { + return sessionStorage.getItem('im-session-token') || ''; +} + +function normalizeResponse(payload: { code?: number; data?: unknown }): unknown { + return payload && payload.code === 0 && payload.data !== undefined ? payload.data : payload; +} + +interface RequestOptions { + method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + body?: unknown; + headers?: Record; + auth?: boolean; + tenantCodeOverride?: string; +} + +/** Core request helper. `auth: false` skips the Authorization header (pre-login calls). */ +export async function imRequest( + path: string, + { method = 'GET', body, headers, auth = true, tenantCodeOverride }: RequestOptions = {}, +): Promise { + const finalHeaders: Record = { + 'Content-Type': 'application/json', + 'X-MeetingIM-APIKey': API_KEY, + 'X-IM-Schema': '1', + 'X-Tenant-Code': tenantCodeOverride || currentTenantCode(), + 'X-Request-ID': requestId(), + ...headers, + }; + if (auth) { + const token = authToken(); + if (token) finalHeaders.Authorization = `Bearer ${token}`; + } + const response = await fetch(apiBase() + path, { + method, + headers: finalHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + let payload: { code?: number; data?: unknown; reason?: string; message?: string; details?: unknown } = {}; + if (text) { + try { + payload = JSON.parse(text); + } catch { + throw new ImApiError( + response.ok ? '接口返回非 JSON 数据' : response.statusText || '接口请求失败', + 'NON_JSON_RESPONSE', + response.status, + text.slice(0, 160), + ); + } + } + if (!response.ok || (payload?.code && payload.code !== 0)) { + const known = translateReason(payload.reason, payload.details); + throw new ImApiError( + known || payload.message || payload.reason || response.statusText || '请求失败', + payload.reason, + response.status, + payload.details, + ); + } + return normalizeResponse(payload) as T; +} + +const idem = () => ({ 'Idempotency-Key': requestId() }); + +export const imGet = (path: string, opts?: RequestOptions) => imRequest(path, opts); +export const imPost = (path: string, body?: unknown, opts: RequestOptions = {}) => + imRequest(path, { method: 'POST', body, headers: idem(), ...opts }); +export const imPatch = (path: string, body?: unknown, opts: RequestOptions = {}) => + imRequest(path, { method: 'PATCH', body, headers: idem(), ...opts }); +export const imPut = (path: string, body?: unknown, opts: RequestOptions = {}) => + imRequest(path, { method: 'PUT', body, headers: idem(), ...opts }); +export const imDelete = (path: string, opts: RequestOptions = {}) => + imRequest(path, { method: 'DELETE', headers: idem(), ...opts }); + +/** + * List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to + * only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real + * answer was on page 2). Empty/repeated next_cursor means done. + */ +export async function fetchAllPages( + fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>, + startCursor = '', +): Promise { + let cursor = startCursor; + let items: T[] = []; + for (;;) { + const page = await fetchPage(cursor); + items = items.concat(page?.items || []); + const nextCursor = page?.next_cursor || ''; + if (!nextCursor || nextCursor === cursor) break; + cursor = nextCursor; + } + return items; +} diff --git a/web/src/lib/api/tenant.ts b/web/src/lib/api/tenant.ts new file mode 100644 index 00000000..2d7a19a2 --- /dev/null +++ b/web/src/lib/api/tenant.ts @@ -0,0 +1,39 @@ +import { imRequest } from './client'; + +/** + * Shape verified against a real `POST /v1/im/tenants/resolve` response (not assumed from memory/ + * docs) -- code/name are nested under `tenant`, not top-level, and the endpoints block includes a + * real resolved `websocket` URL alongside `api`/`file`. + */ +export interface ResolveTenantResponse { + tenant: { code: string; name: string; tenant_desc?: string }; + endpoints: { api: string; file: string; websocket: string }; + auth_methods?: string[]; + password_policy?: { min_length?: number; max_length?: number }; +} + +export async function resolveTenant(tenantCode: string): Promise { + const res = await imRequest('/tenants/resolve', { + method: 'POST', + auth: false, + tenantCodeOverride: tenantCode, + body: { tenant_code: tenantCode, platform: 'web', app_version: __APP_VERSION__ }, + }); + // The real backend always answers with absolute URLs (https://api.118im.com/...). In prod that's + // same-origin with where this app is actually deployed, so it's harmless there -- but in local + // dev those absolute URLs would bypass vite.config.ts's same-origin proxy and hit a real CORS + // wall. Dev-only: rewrite api/file back to the relative paths the proxy is listening on. The + // `websocket` URL is deliberately left absolute even in dev -- WS isn't subject to fetch()'s + // same-origin policy, so the Tinode connection always talks to the real host directly. + if (import.meta.env.DEV) { + return { + ...res, + endpoints: { + ...res.endpoints, + api: res.endpoints.api.replace(/^https?:\/\/[^/]+/, ''), + file: res.endpoints.file.replace(/^https?:\/\/[^/]+/, ''), + }, + }; + } + return res; +} diff --git a/web/src/lib/tinode/connection.ts b/web/src/lib/tinode/connection.ts new file mode 100644 index 00000000..813b32b8 --- /dev/null +++ b/web/src/lib/tinode/connection.ts @@ -0,0 +1,107 @@ +// Singleton Tinode connection + a small pub-sub layer wrapping it for React. +// +// The SDK's onConnect/onDisconnect/onLogin etc. are plain single-slot properties (`tinode.onConnect +// = fn`), not a real multi-listener event emitter -- assigning one overwrites any previous +// assignment. If more than one React component tried to register its own callback directly (e.g. +// each calling useSyncExternalStore with its own subscribe function touching these slots), they'd +// silently stomp on each other. This module claims all the SDK's callback slots exactly once and +// fans them out to any number of subscribers via its own Set, which is what hooks.ts's +// useSyncExternalStore-based hooks actually subscribe to. +import { Tinode } from 'tinode-sdk'; + +import { API_KEY, APP_NAME, LOGGING_ENABLED } from '@/lib/config'; +import { useSessionStore } from '@/stores/session'; + +export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated'; + +let tinode: Tinode | null = null; +let status: ConnectionStatus = 'disconnected'; +const listeners = new Set<() => void>(); + +function setStatus(next: ConnectionStatus) { + if (status === next) return; + status = next; + listeners.forEach((cb) => cb()); +} + +/** + * The server extends the stock {hi} packet with a `tenant` field (server/datamodel.go) that the + * vendored JS SDK has no API for. `#initPacket`/the connection's packet builder is private, so the + * only usable hook is wrapping the connection's raw sendText and injecting the field into the + * already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is + * a real backend protocol extension, not an old-codebase-specific workaround. + */ +function installTenantHiPatch(t: Tinode) { + const conn = t._connection; + if (!conn || conn.__tenantPatched) return; + const original = conn.sendText.bind(conn); + conn.sendText = (msg: unknown) => { + if (typeof msg === 'string' && msg.indexOf('"hi":') >= 0) { + try { + const parsed = JSON.parse(msg); + if (parsed.hi) { + const tenant = useSessionStore.getState().tenant; + if (tenant?.code) { + parsed.hi.tenant = tenant.code; + msg = JSON.stringify(parsed); + } + } + } catch { + // Not valid JSON -- send as is. + } + } + return original(msg as string); + }; + conn.__tenantPatched = true; +} + +function createTinode(): Tinode { + const tenant = useSessionStore.getState().tenant; + const host = tenant?.host || 'web.118im.com'; + const secure = tenant?.secure ?? true; + const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false }); + t.setHumanLanguage('zh'); + t.enableLogging(LOGGING_ENABLED, true); + installTenantHiPatch(t); + + t.onConnect = () => setStatus('connected'); + t.onDisconnect = () => setStatus('disconnected'); + t.onLogin = (code: number) => { + if (code === 200) setStatus('authenticated'); + }; + + return t; +} + +/** Lazily creates the singleton connection. Must only be called once a tenant is resolved. */ +export function getTinode(): Tinode { + if (!tinode) { + tinode = createTinode(); + } + return tinode; +} + +/** Tears down the singleton so the next getTinode() rebuilds against a freshly-resolved tenant. */ +export function resetTinode() { + if (tinode?.isConnected()) { + tinode.disconnect(); + } + tinode = null; + setStatus('disconnected'); +} + +export async function connect(): Promise { + const t = getTinode(); + if (t.isConnected()) return; + setStatus('connecting'); + await t.connect(); +} + +export function getConnectionStatus(): ConnectionStatus { + return status; +} + +export function subscribeConnectionStatus(callback: () => void): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} diff --git a/web/src/lib/tinode/hooks.ts b/web/src/lib/tinode/hooks.ts new file mode 100644 index 00000000..806580b4 --- /dev/null +++ b/web/src/lib/tinode/hooks.ts @@ -0,0 +1,13 @@ +import { useSyncExternalStore } from 'react'; + +import { getConnectionStatus, getTinode, subscribeConnectionStatus } from './connection'; + +/** The current WS connection status, reactive via useSyncExternalStore. */ +export function useConnectionStatus() { + return useSyncExternalStore(subscribeConnectionStatus, getConnectionStatus); +} + +/** The singleton Tinode client instance (stable reference, not itself reactive). */ +export function useTinode() { + return getTinode(); +} diff --git a/web/src/lib/tinode/tinode-sdk.d.ts b/web/src/lib/tinode/tinode-sdk.d.ts index 17dc23f6..4632b944 100644 --- a/web/src/lib/tinode/tinode-sdk.d.ts +++ b/web/src/lib/tinode/tinode-sdk.d.ts @@ -19,6 +19,10 @@ declare module 'tinode-sdk' { export type AuthLevel = 'anon' | 'auth' | 'root'; + export interface LoginCtrl { + ctrl: { code: number; text?: string; params?: Record }; + } + export class AccessMode { mode: string; given: string; @@ -101,8 +105,9 @@ declare module 'tinode-sdk' { isAuthenticated(): boolean; getAuthenticatedUserID(): string | undefined; - login(scheme: string, secret: string, cred?: unknown): Promise<{ ctrl: { code: number; params?: Record } }>; - loginToken(token: string, cred?: unknown): Promise<{ ctrl: { code: number } }>; + login(scheme: string, secret: string, cred?: unknown): Promise; + loginBasic(username: string, password: string, cred?: unknown): Promise; + loginToken(token: string, cred?: unknown): Promise; getAuthToken(): { token: string; expires: Date } | null; getMeTopic(): MeTopic; diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts new file mode 100644 index 00000000..4fc9940f --- /dev/null +++ b/web/src/stores/session.ts @@ -0,0 +1,48 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface TenantContext { + code: string; + name: string; + endpoints: { api: string; file: string }; + /** Real Tinode WS host + scheme, parsed from tenants/resolve's `endpoints.websocket`. */ + host: string; + secure: boolean; +} + +interface SessionState { + tenant: TenantContext | null; + myUserId: string | null; + setTenant: (tenant: TenantContext) => void; + clearTenant: () => void; + setMyUserId: (id: string | null) => void; +} + +/** + * Tenant context persists across browser restarts (localStorage) the same way the old app kept + * it, since a returning user's auto-login still needs it to build the {hi} packet and every REST + * call's X-Tenant-Code header. `myUserId` is *not* persisted here -- it's re-derived from the + * Tinode connection's own auth state on reconnect, not something to trust stale from a previous + * session. + */ +export const useSessionStore = create()( + persist( + (set) => ({ + tenant: null, + myUserId: null, + setTenant: (tenant) => set({ tenant }), + clearTenant: () => set({ tenant: null }), + setMyUserId: (id) => set({ myUserId: id }), + }), + { + name: 'meetingim-session', + partialize: (state) => ({ tenant: state.tenant }), + }, + ), +); + +/** Parses `wss://host:port/v0/channels` (or `ws://...`) into Tinode's own {host, secure} shape. */ +export function parseWebsocketEndpoint(url: string): { host: string; secure: boolean } { + const parsed = new URL(url); + return { host: parsed.host, secure: parsed.protocol === 'wss:' }; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 2612d6ee..5488b850 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -19,5 +19,16 @@ export default defineConfig({ }, server: { port: 8094, + // Same-origin dev proxy for the REST layer only -- avoids CORS entirely (the browser only ever + // sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file") + // work unmodified in local dev, same idea as the old app's hand-rolled dev-proxy-server.js, just + // using Vite's built-in proxy instead of a bespoke Node server. The Tinode WS connection itself + // does NOT need this: WebSocket isn't subject to the fetch() same-origin policy, so it connects + // straight to the tenant's real host regardless (confirmed working this way throughout the old + // app's development too). + proxy: { + '/v1/im': { target: 'https://api.118im.com', changeOrigin: true, secure: true }, + '/v0/file': { target: 'https://api.118im.com', changeOrigin: true, secure: true }, + }, }, })