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

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,8 +1,7 @@
1
1
  {
2
2
  "name": "@autional-cn/shared",
3
- "version": "0.1.0-rc",
3
+ "version": "0.1.0-rc.1",
4
4
  "description": "Autional shared layer — API client, auth, types, hooks, utils",
5
- "license": "AGPL-3.0",
6
5
  "main": "./src/index.ts",
7
6
  "module": "./src/index.ts",
8
7
  "types": "./src/index.ts",
@@ -15,9 +14,6 @@
15
14
  "./generated/queries": "./src/generated/queries.ts",
16
15
  "./package.json": "./package.json"
17
16
  },
18
- "files": [
19
- "src"
20
- ],
21
17
  "sideEffects": false,
22
18
  "dependencies": {
23
19
  "@opentelemetry/api": "1.9.1",
@@ -27,19 +23,24 @@
27
23
  "zustand": "5.0.13"
28
24
  },
29
25
  "devDependencies": {
26
+ "@autional-cn/tsconfig": "0.1.0-rc",
30
27
  "@types/lodash-es": "4.17.12",
31
28
  "@types/node": "26.1.2",
32
29
  "@types/react": "19.2.14",
33
30
  "@types/react-dom": "19.2.3",
34
31
  "react": "19.2.5",
35
32
  "react-dom": "19.2.5",
36
- "typescript": "5.8.3",
37
- "vitest": "3.2.0"
33
+ "tsup": "8.5.1",
34
+ "typescript": "5.8.3"
38
35
  },
39
36
  "peerDependencies": {
40
37
  "react": ">=18.0.0",
41
38
  "react-dom": ">=18.0.0"
42
39
  },
40
+ "license": "AGPL-3.0",
41
+ "files": [
42
+ "src"
43
+ ],
43
44
  "scripts": {
44
45
  "typecheck": "tsc --noEmit",
45
46
  "test": "vitest run"
@@ -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
@@ -157,16 +157,14 @@ export {
157
157
  export { AuditStatsOnly } from './components/AuditStatsOnly';
158
158
  export { TenantIndexGuard, usePublicTenantSlugs } from './components/TenantIndexGuard';
159
159
  export type { TenantIndexGuardProps } from './components/TenantIndexGuard';
160
+ export { TenantRootRedirect } from './components/TenantRootRedirect';
160
161
  export { buildTenantUrls } from './auth/build-tenant-urls';
161
162
  export { resolvePortalBasename } from './config/resolve-basename';
162
163
 
163
- // 这里原来有 4 行 re-export:GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes,
164
- // 全部指向 @autional-cn/api-generated。2026-09 实测:**全舰队 0 处使用它们**
165
- // (唯一的出现就是这 4 行自己),而它们正是那份 file: 依赖的唯一消费者。
166
- // 删掉之后 shared 才能作为 npm 包发布——否则发布出去的清单里会留着一条
167
- // file:../../scripts/generate/api-generated-package,消费方永远解析不到。
168
- // 真正的生成代码走的是 @autional-cn/shared/generated/{api,types,queries}(src/generated/*),
169
- // 五个子路径里全舰队都在用的是那一组,不是这一组。
164
+ // 这里原来有 4 行 re-export(GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes),
165
+ // 全部指向 @autional-cn/api-generated。全舰队对这 4 个名字的使用 = 0 处,
166
+ // 而它们正是那份 file: 依赖的唯一消费者 —— 删掉之后 shared 才能作为 npm 包发布。
167
+ // 真正在用的生成代码是 @autional-cn/shared/generated/{api,types,queries}(包内 src/generated/*)。
170
168
 
171
169
  // Branding(租户品牌 → CSS 变量 / favicon / customCss)
172
170
  export {