@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
|
|
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', () => {
|
package/src/config/index.ts
CHANGED
|
@@ -177,15 +177,19 @@ function resolvePortalUrl(entry: PortalEntry, hostname: string, slug?: string):
|
|
|
177
177
|
return url;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
// 租户段门户白名单:仅这些门户的入口带 /<tenantSlug>(门户 URL
|
|
181
|
-
//
|
|
182
|
-
|
|
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';
|