Files
im_webapp/web/src/features/auth/TenantEntryPage.tsx
T

114 lines
5.7 KiB
TypeScript

import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { ImApiError } from '@/lib/api/client';
import { resolveTenant } from '@/lib/api/tenant';
import { BASE_APP_NAME } from '@/lib/config';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
export function TenantEntryPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setTenant = useSessionStore((s) => s.setTenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const myUserId = useSessionStore((s) => s.myUserId);
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;
// 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. `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 {
const res = await resolveTenant(code.trim());
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
// createTinode() only ever reads useSessionStore's tenant at that one construction moment.
resetTinode();
// The confirm() dialog above literally promises "切换企业将退出当前登录" -- without these,
// that was a false promise: `myUserId` is a separate module-level store that switching
// tenant never touched, so RequireAuth's `if (myUserId) return <Outlet/>` would let a
// subsequent in-app navigation straight through with the OLD tenant's uid and the OLD
// tenant's sessionStorage token, against a freshly-reset Tinode connection that has never
// authenticated with the NEW tenant at all. Same teardown SettingsLayout's own logout and
// SecuritySettingsPage's account deletion already do.
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
queryClient.clear();
setTenant({
code: res.tenant.code,
name: res.tenant.name,
endpoints: res.endpoints,
host,
secure,
passwordPolicy: res.password_policy
? { minLength: res.password_policy.min_length ?? 8, maxLength: res.password_policy.max_length ?? 64 }
: undefined,
});
navigate('/login');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
<Link to="/enterprise-register" state={{ tenantCodeHint: code.trim() }} className="absolute right-6 top-6 text-sm text-accent no-underline">
企业注册
</Link>
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
<div className="mb-2 flex flex-col items-center gap-3">
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
<strong className="text-[26px] font-bold text-text-title">{BASE_APP_NAME}</strong>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<Icon name="business" className="text-[20px] text-focus-border" />
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入企业码"
maxLength={64}
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<button
type="submit"
disabled={!code.trim() || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '进入中…' : '进入企业'}
</button>
</form>
<Link to="/feedback" state={{ tenantCodeHint: code.trim() }} className="absolute bottom-8 text-sm text-accent no-underline">
遇到问题?
</Link>
</div>
);
}