@autional-cn/shared 0.1.0-rc → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autional-cn/shared",
3
- "version": "0.1.0-rc",
3
+ "version": "0.1.0-rc.2",
4
4
  "description": "Autional shared layer — API client, auth, types, hooks, utils",
5
5
  "license": "AGPL-3.0",
6
6
  "main": "./src/index.ts",
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@opentelemetry/api": "1.9.1",
24
24
  "@tanstack/react-query": "5.100.9",
25
+ "@types/lodash-es": "4.17.12",
26
+ "@types/node": "26.1.2",
27
+ "@types/react": "19.2.14",
28
+ "@types/react-dom": "19.2.3",
25
29
  "axios": "1.16.0",
26
30
  "lodash-es": "4.18.1",
27
31
  "zustand": "5.0.13"
28
32
  },
29
33
  "devDependencies": {
30
- "@types/lodash-es": "4.17.12",
31
- "@types/node": "26.1.2",
32
- "@types/react": "19.2.14",
33
- "@types/react-dom": "19.2.3",
34
34
  "react": "19.2.5",
35
35
  "react-dom": "19.2.5",
36
36
  "typescript": "5.8.3",
@@ -0,0 +1,45 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { useCurrentTenantId } from '../hooks/useAuth';
3
+ import { getPortalUrl } from '../config';
4
+ import { usePublicTenantSlugs } from './TenantIndexGuard';
5
+
6
+ /**
7
+ * 裸根 `/` 漏斗:门户入口必须带租户段(/:tenantSlug)。
8
+ * - 已有会话且能解析出 slug → 整页跳 /<slug>/(不经过 brand)
9
+ * - 否则整页跳 brand 选品牌(brand → auth 登录 → 回本门户 /<slug>/)
10
+ *
11
+ * slug 解析**唯一权威 = 公开租户名单**(id → name,name 即 slug)。
12
+ * 不得用会话 store 的 tenants[].name —— 那是 identity `/auth/me/tenants` 的
13
+ * **展示名**(如 "Demo Tenant"),拼进 URL 会落 /Demo%20Tenant/ → 404(回归修复;
14
+ * 与 auth 站 8859e4d 的 pickSessionSlug 同口径)。
15
+ *
16
+ * 注:本包刻意不依赖 react-router(它只属于各 app),故不走 <Navigate>。
17
+ */
18
+ export function TenantRootRedirect() {
19
+ const currentTenantId = useCurrentTenantId();
20
+ const { data: knownTenants, isSuccess: tenantsLoaded } = usePublicTenantSlugs();
21
+
22
+ const slug = useMemo(() => {
23
+ if (!currentTenantId || !tenantsLoaded) return undefined;
24
+ const match = (knownTenants ?? []).find((t) => t.id === currentTenantId);
25
+ return match?.name || match?.slug || undefined;
26
+ }, [currentTenantId, knownTenants, tenantsLoaded]);
27
+
28
+ useEffect(() => {
29
+ if (typeof window === 'undefined') return;
30
+ if (slug) {
31
+ const base = window.location.pathname.replace(/\/+$/, '');
32
+ window.location.replace(`${base}/${slug}/`);
33
+ return;
34
+ }
35
+ // 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
36
+ // 无会话(currentTenantId 空)不受此闸,立即漏斗。
37
+ if (currentTenantId && !tenantsLoaded) return;
38
+ const target = `${getPortalUrl('brand')}/?redirect=${encodeURIComponent(
39
+ window.location.origin + window.location.pathname,
40
+ )}`;
41
+ window.location.replace(target);
42
+ }, [slug, currentTenantId, tenantsLoaded]);
43
+
44
+ return null;
45
+ }
@@ -0,0 +1,158 @@
1
+ // @vitest-environment jsdom
2
+ import '@testing-library/jest-dom/vitest';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { render, cleanup, waitFor } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+ import type { ReactNode } from 'react';
7
+
8
+ // ============================================================
9
+ // F-W7-a:TenantRootRedirect 的裸根 slug 解析
10
+ // - slug 解析**唯一权威 = 公开租户名单**(id → name,name 即 slug)
11
+ // - 会话 store 的 tenants[].name 是**展示名**("Demo Tenant"),拼进 URL 会落
12
+ // /Demo%20Tenant/ → 404(回归修复;与 auth 站 8859e4d pickSessionSlug 同口径)
13
+ // - 有会话但公开名单未返回:等待,不抢跑漏斗 brand
14
+ // ============================================================
15
+
16
+ const { mockState } = vi.hoisted(() => ({
17
+ mockState: {
18
+ currentTenantId: null as string | null,
19
+ // 会话 store 的租户(展示名口径)——回归锁:组件若回退用它,断言必红
20
+ storeTenants: [] as Array<{ id: string; name: string; role: string }>,
21
+ replace: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ vi.mock('../../hooks/useAuth', async (importOriginal) => ({
26
+ ...(await importOriginal<typeof import('../../hooks/useAuth')>()),
27
+ useCurrentTenantId: () => mockState.currentTenantId,
28
+ useTenants: () => mockState.storeTenants,
29
+ }));
30
+
31
+ import { TenantRootRedirect } from '../TenantRootRedirect';
32
+ import { getPortalUrl } from '../../config';
33
+
34
+ function wrapper(children: ReactNode) {
35
+ return (
36
+ <QueryClientProvider
37
+ client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })}
38
+ >
39
+ {children}
40
+ </QueryClientProvider>
41
+ );
42
+ }
43
+
44
+ function stubTenantList(items: unknown[]) {
45
+ vi.stubGlobal(
46
+ 'fetch',
47
+ vi.fn().mockResolvedValue({
48
+ ok: true,
49
+ status: 200,
50
+ json: async () => ({ code: 0, message: 'success', items, total: items.length }),
51
+ }),
52
+ );
53
+ }
54
+
55
+ function expectedBrandFunnel() {
56
+ return `${getPortalUrl('brand')}/?redirect=${encodeURIComponent(
57
+ window.location.origin + window.location.pathname,
58
+ )}`;
59
+ }
60
+
61
+ const originalWindowLocation = window.location;
62
+
63
+ beforeEach(() => {
64
+ vi.clearAllMocks();
65
+ mockState.currentTenantId = null;
66
+ mockState.storeTenants = [];
67
+ window.history.pushState({}, '', '/');
68
+ delete (window as any).__APP_CONFIG__;
69
+
70
+ // replace 拦截 + 关键读取面委托真身(pushState 后仍反映当前值)
71
+ delete (window as any).location;
72
+ (window as any).location = Object.defineProperties(
73
+ {},
74
+ {
75
+ ...Object.getOwnPropertyDescriptors(originalWindowLocation),
76
+ protocol: { get: () => originalWindowLocation.protocol, configurable: true },
77
+ hostname: { get: () => originalWindowLocation.hostname, configurable: true },
78
+ origin: { get: () => originalWindowLocation.origin, configurable: true },
79
+ href: { get: () => originalWindowLocation.href, configurable: true },
80
+ pathname: { get: () => originalWindowLocation.pathname, configurable: true },
81
+ replace: { get: () => mockState.replace, configurable: true },
82
+ },
83
+ );
84
+ });
85
+
86
+ afterEach(() => {
87
+ if (window.location !== originalWindowLocation) {
88
+ Object.defineProperty(window, 'location', { value: originalWindowLocation, writable: true });
89
+ }
90
+ cleanup();
91
+ vi.unstubAllGlobals();
92
+ });
93
+
94
+ describe('TenantRootRedirect 裸根漏斗', () => {
95
+ it('有会话 + 名单命中 → 跳 /<slug>/,且 slug 取自公开名单(非展示名)', async () => {
96
+ mockState.currentTenantId = 't1';
97
+ // 会话 store 里是展示名(老缺陷来源)——组件不得使用
98
+ mockState.storeTenants = [{ id: 't1', name: 'Demo Tenant', role: 'owner' }];
99
+ stubTenantList([{ id: 't1', name: 'demo', display_name: 'Demo Tenant' }]);
100
+
101
+ render(wrapper(<TenantRootRedirect />));
102
+
103
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
104
+ const url = String(mockState.replace.mock.calls[0][0]);
105
+ expect(url).toBe('/demo/');
106
+ expect(url).not.toContain('Demo%20Tenant');
107
+ expect(url).not.toContain('Demo');
108
+ });
109
+
110
+ it('名单项只有 slug 字段(无 name)→ 回退 slug', async () => {
111
+ mockState.currentTenantId = 't1';
112
+ stubTenantList([{ id: 't1', slug: 'demo-fallback' }]);
113
+
114
+ render(wrapper(<TenantRootRedirect />));
115
+
116
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
117
+ expect(String(mockState.replace.mock.calls[0][0])).toBe('/demo-fallback/');
118
+ });
119
+
120
+ it('有会话但名单未返回(加载中)→ 等待,不抢跑 brand 漏斗', async () => {
121
+ mockState.currentTenantId = 't1';
122
+ vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
123
+
124
+ render(wrapper(<TenantRootRedirect />));
125
+
126
+ await new Promise((r) => setTimeout(r, 60));
127
+ expect(mockState.replace).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it('无会话 → 漏斗 brand(带 redirect=当前 origin+pathname)', async () => {
131
+ stubTenantList([{ id: 't1', name: 'demo' }]);
132
+
133
+ render(wrapper(<TenantRootRedirect />));
134
+
135
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
136
+ expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
137
+ });
138
+
139
+ it('有会话但名单无匹配(陈旧会话)→ 漏斗 brand', async () => {
140
+ mockState.currentTenantId = 't-unknown';
141
+ stubTenantList([{ id: 't1', name: 'demo' }]);
142
+
143
+ render(wrapper(<TenantRootRedirect />));
144
+
145
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
146
+ expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
147
+ });
148
+
149
+ it('名单接口失败(返回空数组)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
150
+ mockState.currentTenantId = 't1';
151
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
152
+
153
+ render(wrapper(<TenantRootRedirect />));
154
+
155
+ await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
156
+ expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
157
+ });
158
+ });
@@ -208,10 +208,10 @@ describe('getPortalUrl', () => {
208
208
  expect(getPortalUrl('authenticator', 'demo')).toBe(
209
209
  'https://authenticator.autional.cn/demo',
210
210
  );
211
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.autional.cn/demo');
211
212
  });
212
213
 
213
214
  it('keeps domain-root portals at root even when a slug is passed', () => {
214
- expect(getPortalUrl('platform', 'demo')).toBe('https://platform.autional.cn');
215
215
  expect(getPortalUrl('status', 'demo')).toBe('https://status.autional.cn');
216
216
  expect(getPortalUrl('trust', 'demo')).toBe('https://trust.autional.cn');
217
217
  expect(getPortalUrl('developer', 'demo')).toBe('https://developer.autional.cn');
@@ -234,9 +234,9 @@ describe('getPortalUrl', () => {
234
234
  });
235
235
  });
236
236
 
237
- it('legacy branch: slug appended for auth, not for platform', () => {
237
+ it('legacy branch: slug appended for auth and platform', () => {
238
238
  expect(getPortalUrl('auth', 'demo')).toBe('https://auth.iam.tianv.com/demo');
239
- expect(getPortalUrl('platform', 'demo')).toBe('https://platform.iam.tianv.com');
239
+ expect(getPortalUrl('platform', 'demo')).toBe('https://platform.iam.tianv.com/demo');
240
240
  });
241
241
 
242
242
  it('default-table branch: slug appended for user, not for status', () => {
@@ -177,15 +177,19 @@ function resolvePortalUrl(entry: PortalEntry, hostname: string, slug?: string):
177
177
  return url;
178
178
  }
179
179
 
180
- // 租户段门户白名单:仅这些门户的入口带 /<tenantSlug>(门户 URL 统一方案 —— user/security/admin/authenticator,auth 一并统一)。
181
- // 其余门户(platform/status/trust/developer/brand/landing)是域根应用,拼租户段会命中不存在的路由 ⇒ 空白/404
182
- //(F-W8:auth dashboard 磁贴曾因此断 4/8)。新增租户段门户时须同步在此登记。
180
+ // 租户段门户白名单:仅这些门户的入口带 /<tenantSlug>(门户 URL 统一方案)。
181
+ // platform 于 09-30 修订加入(F-W8 再修):platform 控制台本就是租户段挂载应用
182
+ // (legacy seed 049 requires_slug:true),且 OAuth client 按 slug 解析 —— 不拼租户段时
183
+ // 控制台拿不到 client,裸根入口退化为登录死循环。其余门户(status/trust/developer/
184
+ // brand/landing)是域根应用,拼租户段会命中不存在的路由 ⇒ 空白/404。
185
+ // 新增租户段门户时须同步在此登记。
183
186
  const SLUG_PORTALS: ReadonlySet<string> = new Set([
184
187
  'auth',
185
188
  'admin',
186
189
  'user',
187
190
  'security',
188
191
  'authenticator',
192
+ 'platform',
189
193
  ]);
190
194
 
191
195
  export function getPortalUrl(portalId: string, slug?: string): string {
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export * from './seo';
128
128
 
129
129
  // Components
130
130
  export { RequireAuth } from './components/RequireAuth';
131
+ export { TenantRootRedirect } from './components/TenantRootRedirect';
131
132
  export { OAuthCallbackPage } from './components/OAuthCallbackPage';
132
133
  export { TenantSlugProvider, useTenantSlug } from './auth/tenant-slug-context';
133
134
  export { useTenantSlugFromUrl, extractSlugFromPath, registerNonTenantSegments, clearNonTenantSegments } from './auth/slug-from-url';