@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.
- package/package.json +7 -2
- package/src/auth/__tests__/auth-trace.test.ts +237 -0
- package/src/auth/__tests__/build-login-url.test.ts +41 -1
- package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -0
- package/src/auth/auth-trace.ts +473 -0
- package/src/auth/oauth-login.ts +15 -3
- package/src/auth/roles.ts +16 -0
- package/src/auth/service.ts +47 -20
- package/src/components/RequireAuth.tsx +6 -1
- package/src/components/TenantRootRedirect.tsx +3 -2
- package/src/components/__tests__/RequireAuth.test.tsx +42 -5
- package/src/components/__tests__/TenantRootRedirect.test.tsx +13 -5
- package/src/hooks/useLogout.ts +3 -1
- package/src/index.ts +12 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
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 →
|
|
117
|
+
it('无可用 client_id → 提示条(可停留)后跳 auth 登录页并带 from_requireauth=1', async () => {
|
|
118
|
+
const RequireAuthFresh = await freshRequireAuth();
|
|
119
|
+
|
|
92
120
|
render(
|
|
93
|
-
<
|
|
121
|
+
<RequireAuthFresh>
|
|
94
122
|
<div>protected</div>
|
|
95
|
-
</
|
|
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
|
-
<
|
|
269
|
+
<RequireAuthFresh notFound={<div data-testid="tenant-404">no such tenant</div>}>
|
|
235
270
|
<div>protected</div>
|
|
236
|
-
</
|
|
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).
|
|
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
|
});
|
package/src/hooks/useLogout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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 {
|