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 (
+
+ );
+}
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 (
+
+ );
+}
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 },
+ },
},
})