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

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.
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo } from 'react';
2
2
  import { useCurrentTenantId } from '../hooks/useAuth';
3
3
  import { getPortalUrl } from '../config';
4
+ import { traceRedirect } from '../auth/auth-trace';
4
5
  import { usePublicTenantSlugs } from './TenantIndexGuard';
5
6
 
6
7
  /**
@@ -29,7 +30,7 @@ export function TenantRootRedirect() {
29
30
  if (typeof window === 'undefined') return;
30
31
  if (slug) {
31
32
  const base = window.location.pathname.replace(/\/+$/, '');
32
- window.location.replace(`${base}/${slug}/`);
33
+ traceRedirect(`${base}/${slug}/`, { reason: 'funnel-slug' });
33
34
  return;
34
35
  }
35
36
  // 有会话但公开名单尚未返回:等待(名单是 slug 唯一权威,不得抢跑漏斗 brand)。
@@ -38,7 +39,7 @@ export function TenantRootRedirect() {
38
39
  const target = `${getPortalUrl('brand')}/?redirect=${encodeURIComponent(
39
40
  window.location.origin + window.location.pathname,
40
41
  )}`;
41
- window.location.replace(target);
42
+ traceRedirect(target, { reason: 'funnel-brand' });
42
43
  }, [slug, currentTenantId, tenantsLoaded]);
43
44
 
44
45
  return null;
@@ -55,8 +55,33 @@ import { getPortalUrl } from '../../config';
55
55
 
56
56
  const originalWindowLocation = window.location;
57
57
 
58
+ /**
59
+ * 每例取全新模块实例:auth-trace 的模块级重入抑制(pending)与「停留」复位
60
+ * 是同页语义,跨用例泄漏会让后续 interstitial 被静默抑制(假红/假绿)。
61
+ */
62
+ async function freshRequireAuth() {
63
+ vi.resetModules();
64
+ const mod = await import('../RequireAuth');
65
+ return mod.RequireAuth;
66
+ }
67
+
68
+ /** authTrace 的提示条/确认页挂在 document.body(非 React 树),手动清理防跨用例残留。 */
69
+ function cleanupAuthTraceNodes() {
70
+ document.getElementById('auth-trace-notice')?.remove();
71
+ document.getElementById('auth-trace-confirm')?.remove();
72
+ }
73
+
74
+ function clickGoNow(): void {
75
+ const btn = Array.from(document.querySelectorAll<HTMLButtonElement>('#auth-trace-notice button')).find(
76
+ (b) => b.textContent === '立即前往',
77
+ );
78
+ expect(btn).toBeTruthy();
79
+ btn!.click();
80
+ }
81
+
58
82
  beforeEach(() => {
59
83
  vi.clearAllMocks();
84
+ document.documentElement.lang = 'zh-CN';
60
85
  mockState.machineStatus = 'unauthenticated';
61
86
  mockState.tenantRoute = {
62
87
  slug: null,
@@ -85,22 +110,30 @@ afterEach(() => {
85
110
  }
86
111
  // vitest globals 未开启 ⇒ RTL 不会自动 cleanup,手动清理防跨用例 DOM 污染
87
112
  cleanup();
113
+ cleanupAuthTraceNodes();
88
114
  });
89
115
 
90
116
  describe('RequireAuth 未登录出口', () => {
91
- it('无可用 client_id → 跳 auth 登录页并带 from_requireauth=1', async () => {
117
+ it('无可用 client_id → 提示条(可停留)后跳 auth 登录页并带 from_requireauth=1', async () => {
118
+ const RequireAuthFresh = await freshRequireAuth();
119
+
92
120
  render(
93
- <RequireAuth>
121
+ <RequireAuthFresh>
94
122
  <div>protected</div>
95
- </RequireAuth>,
123
+ </RequireAuthFresh>,
96
124
  );
97
125
 
126
+ // 层①:被动弹登录先出提示条(不再静默整页弹走)
127
+ await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
128
+ clickGoNow();
129
+
98
130
  await waitFor(() => {
99
131
  expect(mockState.replace).toHaveBeenCalled();
100
132
  });
101
133
  const url = String(mockState.replace.mock.calls[0][0]);
102
134
  expect(url).toContain('from_requireauth=1');
103
135
  expect(url).toContain('redirect=');
136
+ expect(url).toContain('rt=localhost.unauthenticated.');
104
137
  expect(url.startsWith(getPortalUrl('auth'))).toBe(true);
105
138
  expect(mockState.initiate).not.toHaveBeenCalled();
106
139
  });
@@ -230,12 +263,16 @@ describe('RequireAuth F-W6 确定性 404 闸门', () => {
230
263
 
231
264
  it('非 unknownSlug(网络错误 fail-open)→ 维持原漏斗(回归锁)', async () => {
232
265
  // 默认 tenantRoute: notFound=true, unknownSlug=false —— 即 by-slug 网络错误场景
266
+ const RequireAuthFresh = await freshRequireAuth();
267
+
233
268
  render(
234
- <RequireAuth notFound={<div data-testid="tenant-404">no such tenant</div>}>
269
+ <RequireAuthFresh notFound={<div data-testid="tenant-404">no such tenant</div>}>
235
270
  <div>protected</div>
236
- </RequireAuth>,
271
+ </RequireAuthFresh>,
237
272
  );
238
273
 
274
+ await waitFor(() => expect(document.getElementById('auth-trace-notice')).toBeTruthy());
275
+ clickGoNow();
239
276
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
240
277
  const url = String(mockState.replace.mock.calls[0][0]);
241
278
  expect(url).toContain('from_requireauth=1');
@@ -58,6 +58,13 @@ function expectedBrandFunnel() {
58
58
  )}`;
59
59
  }
60
60
 
61
+ /** 剥除 authTrace 面包屑(rt=)后比较;rt 装饰本身由专门断言锁定。 */
62
+ function withoutRt(url: string): string {
63
+ const u = new URL(url, 'http://trace.invalid');
64
+ u.searchParams.delete('rt');
65
+ return u.pathname + u.search;
66
+ }
67
+
61
68
  const originalWindowLocation = window.location;
62
69
 
63
70
  beforeEach(() => {
@@ -102,7 +109,8 @@ describe('TenantRootRedirect 裸根漏斗', () => {
102
109
 
103
110
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
104
111
  const url = String(mockState.replace.mock.calls[0][0]);
105
- expect(url).toBe('/demo/');
112
+ expect(url).toContain('rt=localhost.funnel-slug.');
113
+ expect(withoutRt(url)).toBe('/demo/');
106
114
  expect(url).not.toContain('Demo%20Tenant');
107
115
  expect(url).not.toContain('Demo');
108
116
  });
@@ -114,7 +122,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
114
122
  render(wrapper(<TenantRootRedirect />));
115
123
 
116
124
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
117
- expect(String(mockState.replace.mock.calls[0][0])).toBe('/demo-fallback/');
125
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe('/demo-fallback/');
118
126
  });
119
127
 
120
128
  it('有会话但名单未返回(加载中)→ 等待,不抢跑 brand 漏斗', async () => {
@@ -133,7 +141,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
133
141
  render(wrapper(<TenantRootRedirect />));
134
142
 
135
143
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
136
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
144
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
137
145
  });
138
146
 
139
147
  it('有会话但名单无匹配(陈旧会话)→ 漏斗 brand', async () => {
@@ -143,7 +151,7 @@ describe('TenantRootRedirect 裸根漏斗', () => {
143
151
  render(wrapper(<TenantRootRedirect />));
144
152
 
145
153
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
146
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
154
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
147
155
  });
148
156
 
149
157
  it('名单接口失败(返回空数组)→ 有会话按无匹配处理,漏斗 brand(不卡死)', async () => {
@@ -153,6 +161,6 @@ describe('TenantRootRedirect 裸根漏斗', () => {
153
161
  render(wrapper(<TenantRootRedirect />));
154
162
 
155
163
  await waitFor(() => expect(mockState.replace).toHaveBeenCalled());
156
- expect(String(mockState.replace.mock.calls[0][0])).toBe(expectedBrandFunnel());
164
+ expect(withoutRt(String(mockState.replace.mock.calls[0][0]))).toBe(withoutRt(expectedBrandFunnel()));
157
165
  });
158
166
  });
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { useCallback } from 'react';
8
8
  import { buildLogoutUrl } from '../auth/roles';
9
+ import { traceRedirect } from '../auth/auth-trace';
9
10
 
10
11
  /**
11
12
  * 统一的登出 Hook
@@ -26,7 +27,8 @@ export function useLogout() {
26
27
 
27
28
  // buildLogoutUrl = auth 域裸根 + `logout=1` 登出意图标记:入口路由据此先
28
29
  // 终结会话再落 brand,而不是把带会话的回程当普通深链直送登录页(会静默重登)。
29
- window.location.href = buildLogoutUrl(currentUrl);
30
+ // funnel:用户主动行为,静默直跳(assign 保持原 href 历史语义);仅记 trace。
31
+ traceRedirect(buildLogoutUrl(currentUrl), { reason: 'logout', assign: true });
30
32
  };
31
33
  performLogout();
32
34
  }, []);
package/src/index.ts CHANGED
@@ -25,6 +25,8 @@ export { generatePKCE, generateCodeChallenge } from './auth/pkce';
25
25
  export type { PKCEPair } from './auth/pkce';
26
26
  export { initiateOAuthLogin, handleOAuthCallback, isOAuthEnabled } from './auth/oauth-login';
27
27
  export { buildLoginUrl } from './auth/roles';
28
+ export { traceRedirect, traceEvent, traceDump } from './auth/auth-trace';
29
+ export type { AuthTraceEntry, AuthTraceRedirectOptions } from './auth/auth-trace';
28
30
 
29
31
  // Types
30
32
  export type {
@@ -161,13 +163,16 @@ export type { TenantIndexGuardProps } from './components/TenantIndexGuard';
161
163
  export { buildTenantUrls } from './auth/build-tenant-urls';
162
164
  export { resolvePortalBasename } from './config/resolve-basename';
163
165
 
164
- // 这里原来有 4 行 re-export:GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes,
165
- // 全部指向 @autional-cn/api-generated。2026-09 实测:**全舰队 0 处使用它们**
166
- // (唯一的出现就是这 4 行自己),而它们正是那份 file: 依赖的唯一消费者。
167
- // 删掉之后 shared 才能作为 npm 包发布——否则发布出去的清单里会留着一条
168
- // file:../../scripts/generate/api-generated-package,消费方永远解析不到。
169
- // 真正的生成代码走的是 @autional-cn/shared/generated/{api,types,queries}(src/generated/*),
170
- // 五个子路径里全舰队都在用的是那一组,不是这一组。
166
+ // 生成代码的命名空间再导出(GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes)。
167
+ // 说明:这 4 行原先指向 @autional-cn/api-generated —— 那是各站逐站副本里用
168
+ // file:../../scripts/generate/api-generated-package 提供的本地包,一旦 shared 走 npm 发布
169
+ // 就必然解析不到(authenticator / security 的 tsc 就是这么炸的:TS2305 has no exported member)。
170
+ // 本包内的 src/generated/* 是同一份生成结果,因此改指包内相对路径;
171
+ // 消费方实际用到的类型面由逐站 tsc 验证。
172
+ export * as GeneratedApi from './generated/api';
173
+ export * as GeneratedTypes from './generated/types';
174
+ export * as ApiGenerated from './generated/api';
175
+ export * as ApiTypes from './generated/types';
171
176
 
172
177
  // Branding(租户品牌 → CSS 变量 / favicon / customCss)
173
178
  export {