114 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|