@autional-cn/shared 0.1.0-rc.3 → 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 +2 -0
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.4",
|
|
4
4
|
"description": "Autional shared layer — API client, auth, types, hooks, utils",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -31,10 +31,15 @@
|
|
|
31
31
|
"zustand": "5.0.13"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
35
|
+
"@testing-library/react": "16.3.2",
|
|
36
|
+
"jsdom": "29.1.1",
|
|
34
37
|
"react": "19.2.5",
|
|
35
38
|
"react-dom": "19.2.5",
|
|
36
39
|
"typescript": "5.8.3",
|
|
37
|
-
"
|
|
40
|
+
"vite": "6.4.2",
|
|
41
|
+
"vitest": "3.2.0",
|
|
42
|
+
"@autional-cn/tsconfig": "0.1.0-rc"
|
|
38
43
|
},
|
|
39
44
|
"peerDependencies": {
|
|
40
45
|
"react": ">=18.0.0",
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// authTrace 三层回归锁(#48)
|
|
6
|
+
// ① interstitial:被动跳转 → 提示条(可停留),延迟后 replace
|
|
7
|
+
// ② debug=auth → 确认页(不自动跳,手动继续/留)
|
|
8
|
+
// ③ 结构化日志:环形缓冲 / 凭据剥离 / ?rt= 跨站面包屑 / dump()
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
const replaceSpy = vi.fn();
|
|
12
|
+
let assignCalls: string[] = [];
|
|
13
|
+
let currentHref = '';
|
|
14
|
+
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
|
15
|
+
|
|
16
|
+
// 只替换 window.location(不动 window 本体 —— stubGlobal 会连 localStorage/sessionStorage
|
|
17
|
+
// 一起遮蔽)。字段给纯值 + href getter/setter,绕开 jsdom Location brand check。
|
|
18
|
+
function mockWindowLocation(pathname: string, search = ''): void {
|
|
19
|
+
currentHref = `https://user.autional.local${pathname}${search}`;
|
|
20
|
+
Object.defineProperty(window, 'location', {
|
|
21
|
+
configurable: true,
|
|
22
|
+
value: {
|
|
23
|
+
pathname,
|
|
24
|
+
search,
|
|
25
|
+
origin: 'https://user.autional.local',
|
|
26
|
+
hostname: 'user.autional.local',
|
|
27
|
+
protocol: 'https:',
|
|
28
|
+
replace: replaceSpy,
|
|
29
|
+
get href() {
|
|
30
|
+
return currentHref;
|
|
31
|
+
},
|
|
32
|
+
set href(v: string) {
|
|
33
|
+
assignCalls.push(v);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 每例取全新模块实例:清 initialized / pending / buffer 模块态。 */
|
|
40
|
+
async function freshTrace() {
|
|
41
|
+
vi.resetModules();
|
|
42
|
+
return await import('../auth-trace');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function notice(): HTMLElement | null {
|
|
46
|
+
return document.getElementById('auth-trace-notice');
|
|
47
|
+
}
|
|
48
|
+
function confirm(): HTMLElement | null {
|
|
49
|
+
return document.getElementById('auth-trace-confirm');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
assignCalls = [];
|
|
55
|
+
sessionStorage.clear();
|
|
56
|
+
document.body.innerHTML = '';
|
|
57
|
+
document.documentElement.lang = 'zh-CN';
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
if (originalLocationDescriptor) {
|
|
62
|
+
Object.defineProperty(window, 'location', originalLocationDescriptor);
|
|
63
|
+
}
|
|
64
|
+
document.body.innerHTML = '';
|
|
65
|
+
vi.useRealTimers();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('层 ③ 结构化日志与缓冲', () => {
|
|
69
|
+
it('traceEvent 写入环形缓冲,50 条封顶(丢最旧)', async () => {
|
|
70
|
+
mockWindowLocation('/demo/');
|
|
71
|
+
const { traceEvent, traceDump } = await freshTrace();
|
|
72
|
+
for (let i = 0; i < 55; i++) traceEvent('evt', { reason: `r${i}` });
|
|
73
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
74
|
+
expect(entries).toHaveLength(50);
|
|
75
|
+
expect(entries[0].reason).toBe('r5');
|
|
76
|
+
expect(entries[49].reason).toBe('r54');
|
|
77
|
+
expect(traceDump()).toContain('r54');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('缓冲中的 URL 剥除凭据类 query(token/code/state 不入 trace)', async () => {
|
|
81
|
+
mockWindowLocation('/demo/dashboard');
|
|
82
|
+
const { traceRedirect } = await freshTrace();
|
|
83
|
+
traceRedirect(
|
|
84
|
+
'https://auth.autional.local/demo/login?redirect=x&access_token=SECRET&code=ABC123&state=st&ok=1',
|
|
85
|
+
{ reason: 'unauthenticated', kind: 'funnel' },
|
|
86
|
+
);
|
|
87
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
88
|
+
const to = entries[entries.length - 1].to as string;
|
|
89
|
+
expect(to).not.toContain('SECRET');
|
|
90
|
+
expect(to).not.toContain('ABC123');
|
|
91
|
+
expect(to).not.toContain('state=');
|
|
92
|
+
expect(to).toContain('ok=1');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('dump() 含环境摘要与条目', async () => {
|
|
96
|
+
mockWindowLocation('/demo/dashboard');
|
|
97
|
+
const { traceEvent, traceDump } = await freshTrace();
|
|
98
|
+
traceEvent('error-exit', { reason: 'session_expired' });
|
|
99
|
+
const dump = traceDump();
|
|
100
|
+
expect(dump).toContain('authTrace v1 @user');
|
|
101
|
+
expect(dump).toContain('error-exit');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('?rt= 跨站面包屑:读取接续为 handoff 条目', async () => {
|
|
105
|
+
mockWindowLocation('/demo/login', '?rt=auth.unauthenticated.abc123&redirect=x');
|
|
106
|
+
const { traceDump } = await freshTrace();
|
|
107
|
+
const dump = traceDump();
|
|
108
|
+
expect(dump).toContain('handoff');
|
|
109
|
+
expect(dump).toContain('auth@');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('层 ① interstitial 提示与停留', () => {
|
|
114
|
+
it('被动跳转:先出提示条,延迟后 replace(URL 带 rt= 面包屑)', async () => {
|
|
115
|
+
mockWindowLocation('/demo/dashboard');
|
|
116
|
+
const { traceRedirect } = await freshTrace();
|
|
117
|
+
vi.useFakeTimers();
|
|
118
|
+
|
|
119
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
120
|
+
reason: 'session-expired',
|
|
121
|
+
kind: 'interstitial',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
125
|
+
expect(notice()).not.toBeNull();
|
|
126
|
+
expect(notice()!.textContent).toContain('会话已过期');
|
|
127
|
+
|
|
128
|
+
vi.advanceTimersByTime(1600);
|
|
129
|
+
expect(notice()).toBeNull();
|
|
130
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('rt=user.session-expired.');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('点「停留」:取消跳转 + pending 复位(后续 funnel 照常执行)', async () => {
|
|
135
|
+
mockWindowLocation('/demo/dashboard');
|
|
136
|
+
const { traceRedirect } = await freshTrace();
|
|
137
|
+
vi.useFakeTimers();
|
|
138
|
+
|
|
139
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
140
|
+
reason: 'session-expired',
|
|
141
|
+
kind: 'interstitial',
|
|
142
|
+
});
|
|
143
|
+
const stayBtn = Array.from(notice()!.querySelectorAll('button')).find((b) =>
|
|
144
|
+
b.textContent!.includes('停留'),
|
|
145
|
+
)!;
|
|
146
|
+
stayBtn.click();
|
|
147
|
+
|
|
148
|
+
expect(notice()).toBeNull();
|
|
149
|
+
vi.advanceTimersByTime(5000);
|
|
150
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
151
|
+
|
|
152
|
+
// pending 已复位:funnel 直跳不受阻
|
|
153
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-slug', kind: 'funnel' });
|
|
154
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('pending 抑制:提示条未决时再来一次 interstitial 被记为 suppressed', async () => {
|
|
158
|
+
mockWindowLocation('/demo/dashboard');
|
|
159
|
+
const { traceRedirect } = await freshTrace();
|
|
160
|
+
vi.useFakeTimers();
|
|
161
|
+
|
|
162
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
163
|
+
traceRedirect('https://auth.autional.local/other/login', { reason: 'unauthenticated', kind: 'interstitial' });
|
|
164
|
+
|
|
165
|
+
const entries = (window as any).__authTrace.entries() as any[];
|
|
166
|
+
expect(entries.filter((e) => e.ev === 'suppressed')).toHaveLength(1);
|
|
167
|
+
vi.advanceTimersByTime(1600);
|
|
168
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('/demo/login');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('funnel(缺省 kind):静默直跳,不出现提示条', async () => {
|
|
173
|
+
mockWindowLocation('/');
|
|
174
|
+
const { traceRedirect } = await freshTrace();
|
|
175
|
+
traceRedirect('https://brand.autional.local/?redirect=x', { reason: 'funnel-brand' });
|
|
176
|
+
expect(notice()).toBeNull();
|
|
177
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('层 ② debug 确认页', () => {
|
|
182
|
+
it('?debug=auth:不自动跳,确认页出现;点「继续前往」才 replace', async () => {
|
|
183
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
184
|
+
const { traceRedirect } = await freshTrace();
|
|
185
|
+
traceRedirect('https://auth.autional.local/demo/login?redirect=x', {
|
|
186
|
+
reason: 'session-expired',
|
|
187
|
+
kind: 'interstitial',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
191
|
+
expect(confirm()).not.toBeNull();
|
|
192
|
+
expect(confirm()!.textContent).toContain('整页跳转确认');
|
|
193
|
+
|
|
194
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
195
|
+
b.textContent!.includes('继续前往'),
|
|
196
|
+
)!;
|
|
197
|
+
goBtn.click();
|
|
198
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('确认页点「留在本页」:不跳 + pending 复位', async () => {
|
|
203
|
+
mockWindowLocation('/demo/dashboard', '?debug=auth');
|
|
204
|
+
const { traceRedirect } = await freshTrace();
|
|
205
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated', kind: 'funnel' });
|
|
206
|
+
|
|
207
|
+
const stayBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
208
|
+
b.textContent!.includes('留在本页'),
|
|
209
|
+
)!;
|
|
210
|
+
stayBtn.click();
|
|
211
|
+
expect(confirm()).toBeNull();
|
|
212
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('debug 标志经 sessionStorage 保持并随跳转透传(无需每次带 ?debug=auth)', async () => {
|
|
216
|
+
mockWindowLocation('/demo/');
|
|
217
|
+
const { traceRedirect } = await freshTrace();
|
|
218
|
+
(window as any).__authTrace.on();
|
|
219
|
+
|
|
220
|
+
traceRedirect('https://auth.autional.local/demo/login', { reason: 'unauthenticated' });
|
|
221
|
+
expect(confirm()).not.toBeNull();
|
|
222
|
+
const goBtn = Array.from(confirm()!.querySelectorAll('button')).find((b) =>
|
|
223
|
+
b.textContent!.includes('继续前往'),
|
|
224
|
+
)!;
|
|
225
|
+
goBtn.click();
|
|
226
|
+
expect(String(replaceSpy.mock.calls[0][0])).toContain('debug=auth');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('assign 语义:opts.assign=true 走 location.href(登录/登出链保持新增历史)', async () => {
|
|
230
|
+
mockWindowLocation('/demo/');
|
|
231
|
+
const { traceRedirect } = await freshTrace();
|
|
232
|
+
traceRedirect('https://auth.autional.local/?logout=1', { reason: 'logout', assign: true });
|
|
233
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
234
|
+
expect(assignCalls).toHaveLength(1);
|
|
235
|
+
expect(assignCalls[0]).toContain('logout=1');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { buildLoginUrl, buildLogoutUrl } from '../roles';
|
|
2
|
+
import { buildLoginUrl, buildLogoutUrl, isAuthLoginSurface } from '../roles';
|
|
3
3
|
|
|
4
4
|
const MOCK_CONFIG = {
|
|
5
5
|
VITE_PORTAL_CONFIG: {
|
|
@@ -217,3 +217,43 @@ describe('buildLoginUrl', () => {
|
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
219
|
});
|
|
220
|
+
|
|
221
|
+
// ============================================================
|
|
222
|
+
// F-W8b 修复②:登录承接面判定(onUnauthorized 抑制整页弹走的判据)
|
|
223
|
+
// 承接面(只清理不导航):`/`、`/login`、`/<slug>/login`、`/error`
|
|
224
|
+
// 非承接面(照常弹登录页):应用页、PKCE 发起页、其它静态路由
|
|
225
|
+
// ============================================================
|
|
226
|
+
function mockPathname(pathname: string): void {
|
|
227
|
+
vi.stubGlobal('window', {
|
|
228
|
+
location: { pathname, hostname: 'auth.autional.local', protocol: 'https:' },
|
|
229
|
+
__APP_CONFIG__: MOCK_CONFIG,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
describe('isAuthLoginSurface', () => {
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
vi.restoreAllMocks();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it.each([
|
|
239
|
+
['裸根入口', '/', true],
|
|
240
|
+
['/login 入口', '/login', true],
|
|
241
|
+
['错误页', '/error', true],
|
|
242
|
+
['租户登录页', '/demo/login', true],
|
|
243
|
+
['带尾斜杠的租户登录页', '/demo/login/', true],
|
|
244
|
+
['应用页 dashboard', '/demo/dashboard', false],
|
|
245
|
+
['租户 index(守卫跳转页)', '/demo', false],
|
|
246
|
+
['裸 dashboard', '/dashboard', false],
|
|
247
|
+
['PKCE 发起页', '/oauth/api/v1/oauth/authorize', false],
|
|
248
|
+
['重置密码页', '/reset-password', false],
|
|
249
|
+
['注册页', '/demo/register', false],
|
|
250
|
+
])('%s(%s)→ %s', (_name, pathname, expected) => {
|
|
251
|
+
mockPathname(pathname as string);
|
|
252
|
+
expect(isAuthLoginSurface()).toBe(expected);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('服务端渲染(无 window)→ false,不抑制', () => {
|
|
256
|
+
vi.stubGlobal('window', undefined);
|
|
257
|
+
expect(isAuthLoginSurface()).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// F-W8b 修复②:会话终结(onUnauthorized)与刷新失败处置的整链回归锁
|
|
6
|
+
// - 已在 auth 登录承接面(`/<slug>/login`、`/error`、裸根入口)→ 只清会话,
|
|
7
|
+
// 不整页弹走(否则 buildLoginUrl 防嵌套截断会丢掉 URL 中的 redirect 回程目标,
|
|
8
|
+
// 且构成一次整页闪烁 —— 存量过期 token 在登录页「静默弹走」)
|
|
9
|
+
// - 非承接面 → 照常清理 + 整页弹登录页(带 slug 与回程 redirect)
|
|
10
|
+
// - refreshToken onFailure:'clear' 只清理不导航;'none' 零副作用;缺省 'redirect'
|
|
11
|
+
// ============================================================
|
|
12
|
+
|
|
13
|
+
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
|
|
14
|
+
|
|
15
|
+
vi.mock('axios', () => ({ default: { post: mockPost } }));
|
|
16
|
+
vi.mock('../../api/bff-client', () => ({ bffLogout: vi.fn(() => Promise.resolve()) }));
|
|
17
|
+
|
|
18
|
+
const replaceSpy = vi.fn();
|
|
19
|
+
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
|
|
20
|
+
|
|
21
|
+
// 只替换 window.location(不动 window 本体 —— stubGlobal 会连 localStorage 一起遮蔽,
|
|
22
|
+
// zustand persist 在 setState 时崩)。字段给纯值,绕开 jsdom Location 的 brand check。
|
|
23
|
+
function mockWindowLocation(pathname: string): void {
|
|
24
|
+
Object.defineProperty(window, 'location', {
|
|
25
|
+
configurable: true,
|
|
26
|
+
value: {
|
|
27
|
+
pathname,
|
|
28
|
+
href: `https://auth.autional.local${pathname}`,
|
|
29
|
+
origin: 'https://auth.autional.local',
|
|
30
|
+
hostname: 'auth.autional.local',
|
|
31
|
+
protocol: 'https:',
|
|
32
|
+
replace: replaceSpy,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
(window as any).__APP_CONFIG__ = { VITE_PORTAL_CONFIG: { auth: { host: 'auth', base: '' } } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 剥除 authTrace 面包屑(rt=)后比较;rt 装饰本身由 toMatch 锁定。 */
|
|
39
|
+
function withoutRt(url: string): string {
|
|
40
|
+
const u = new URL(url);
|
|
41
|
+
u.searchParams.delete('rt');
|
|
42
|
+
return u.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 每例取全新模块实例:清零 service.ts 的模块级重入锁(redirectingToLogin)。 */
|
|
46
|
+
async function freshService() {
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
const svc = await import('../service');
|
|
49
|
+
const store = await import('../store');
|
|
50
|
+
return { AuthService: svc.AuthService, useAuthStore: store.useAuthStore };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
localStorage.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
if (originalLocationDescriptor) {
|
|
60
|
+
Object.defineProperty(window, 'location', originalLocationDescriptor);
|
|
61
|
+
}
|
|
62
|
+
delete (window as any).__APP_CONFIG__;
|
|
63
|
+
vi.useRealTimers();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('onUnauthorized 承接面抑制(F-W8b 修复②)', () => {
|
|
67
|
+
it('已在 /<slug>/login:清理会话但不整页弹走(回程 redirect 不丢)', async () => {
|
|
68
|
+
mockWindowLocation('/demo/login');
|
|
69
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
70
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
71
|
+
|
|
72
|
+
AuthService.onUnauthorized();
|
|
73
|
+
|
|
74
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
75
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
76
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('已在 /error:同样只清理不弹走(错误页有自身倒计时出口)', async () => {
|
|
80
|
+
mockWindowLocation('/error');
|
|
81
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
82
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
83
|
+
|
|
84
|
+
AuthService.onUnauthorized();
|
|
85
|
+
|
|
86
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
87
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('非承接面 /demo/dashboard:清理 + 整页弹登录页(带 slug 与回程 redirect)', async () => {
|
|
91
|
+
mockWindowLocation('/demo/dashboard');
|
|
92
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
93
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-old', isAuthenticated: true });
|
|
94
|
+
|
|
95
|
+
vi.useFakeTimers();
|
|
96
|
+
AuthService.onUnauthorized();
|
|
97
|
+
vi.runAllTimers();
|
|
98
|
+
|
|
99
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
100
|
+
expect(replaceSpy).toHaveBeenCalledTimes(1);
|
|
101
|
+
const url = String(replaceSpy.mock.calls[0][0]);
|
|
102
|
+
expect(url).toMatch(/[?&]rt=auth\.session-expired\.[0-9a-z]+$/);
|
|
103
|
+
expect(withoutRt(url)).toBe(
|
|
104
|
+
'https://auth.autional.local/demo/login?redirect=' +
|
|
105
|
+
encodeURIComponent('https://auth.autional.local/demo/dashboard') +
|
|
106
|
+
'&from_requireauth=1',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('refreshToken onFailure 处置', () => {
|
|
112
|
+
it("'clear':刷新失败只清理会话,不做任何导航(导航权交调用方)", async () => {
|
|
113
|
+
mockWindowLocation('/demo/dashboard');
|
|
114
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
115
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-opaque-1', isAuthenticated: true });
|
|
116
|
+
mockPost.mockRejectedValue(new Error('refresh rejected'));
|
|
117
|
+
|
|
118
|
+
const at = await AuthService.refreshToken({ onFailure: 'clear' });
|
|
119
|
+
|
|
120
|
+
expect(at).toBeNull();
|
|
121
|
+
expect(useAuthStore.getState().accessToken).toBeNull();
|
|
122
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("'none':刷新失败零副作用(会话保留、不导航)", async () => {
|
|
126
|
+
mockWindowLocation('/demo/dashboard');
|
|
127
|
+
const { AuthService, useAuthStore } = await freshService();
|
|
128
|
+
useAuthStore.setState({ accessToken: 'at-old', refreshToken: 'rt-opaque-1', isAuthenticated: true });
|
|
129
|
+
mockPost.mockRejectedValue(new Error('refresh rejected'));
|
|
130
|
+
|
|
131
|
+
const at = await AuthService.refreshToken({ onFailure: 'none' });
|
|
132
|
+
|
|
133
|
+
expect(at).toBeNull();
|
|
134
|
+
expect(useAuthStore.getState().accessToken).toBe('at-old');
|
|
135
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(true);
|
|
136
|
+
expect(replaceSpy).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authTrace —— auth 整页跳转的统一出口与三层可观测(#48)。
|
|
3
|
+
*
|
|
4
|
+
* 三层:
|
|
5
|
+
* ① interstitial 提示:**被动**整页跳转(会话终结 / 未认证深链)执行前显示提示条
|
|
6
|
+
* (原因 + 目标 host + 停留 / 立即前往),短延迟后自动前往。「停留」取消本次跳转;
|
|
7
|
+
* 页面数据面虽已失效,但用户知情(消除「静默弹走」)。
|
|
8
|
+
* ② confirm 确认页:`?debug=auth`(或 __authTrace.on())时所有整页跳转不自动执行,
|
|
9
|
+
* 停在遮罩确认页(跳转详情 + 最近 trace 列表 + 继续 / 留)。debug 标志随跳转
|
|
10
|
+
* 跨站透传,排障可全程跟随。
|
|
11
|
+
* ③ 结构化日志:`[auth-trace]` console 行 + sessionStorage 环形缓冲(跨 SPA 路由、
|
|
12
|
+
* 同 tab 存活)+ `?rt=` 跨站面包屑(目标 URL 追加,下游站读取接续)+
|
|
13
|
+
* `__authTrace.dump()` 一键导出(排障取证)。
|
|
14
|
+
*
|
|
15
|
+
* kind 语义(提示层的适用面):
|
|
16
|
+
* - 'interstitial':被动跳转(会话终结 / buildLoginUrl 弹登录)→ 提示 + 延迟(可停留)。
|
|
17
|
+
* - 'funnel'(缺省):预期漏斗(裸根 → brand / 登出 / OAuth 发起与回跳)→ 静默直跳,
|
|
18
|
+
* 仅记 trace(用户主动或流程本身的必经步骤,加延迟只拖慢)。
|
|
19
|
+
* - debug 模式不分 kind,一律确认页。
|
|
20
|
+
*
|
|
21
|
+
* 隐私红线:缓冲与面包屑中的 URL 一律剥除凭据类 query(token/code/state 等),
|
|
22
|
+
* 不落任何 PII。模块零依赖(不 import React / 各站设施),显式调用即用。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BUFFER_KEY = 'auth-trace-v1';
|
|
26
|
+
const DEBUG_KEY = 'auth-trace-debug';
|
|
27
|
+
const RT_PARAM = 'rt';
|
|
28
|
+
const DEBUG_PARAM = 'debug';
|
|
29
|
+
const DEBUG_VALUE = 'auth';
|
|
30
|
+
const BUFFER_LIMIT = 50;
|
|
31
|
+
const INTERSTITIAL_DELAY_MS = 1500;
|
|
32
|
+
const Z_INDEX = '2147483000';
|
|
33
|
+
|
|
34
|
+
/** URL query 中不得进入 trace 缓冲的凭据类键(前缀匹配)。 */
|
|
35
|
+
const SENSITIVE_KEYS = ['token', 'access_token', 'refresh_token', 'id_token', 'code', 'state', 'secret', 'password'];
|
|
36
|
+
|
|
37
|
+
export interface AuthTraceEntry {
|
|
38
|
+
/** epoch ms */
|
|
39
|
+
t: number;
|
|
40
|
+
/** 事件类型:redirect / handoff / stay / suppressed / error-exit / … */
|
|
41
|
+
ev: string;
|
|
42
|
+
reason?: string;
|
|
43
|
+
from?: string;
|
|
44
|
+
to?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AuthTraceRedirectOptions {
|
|
48
|
+
reason: string;
|
|
49
|
+
kind?: 'interstitial' | 'funnel';
|
|
50
|
+
/** true 用 location.href(新增历史),缺省 false 用 location.replace */
|
|
51
|
+
assign?: boolean;
|
|
52
|
+
/** 覆盖缺省延迟(ms),仅 interstitial 生效 */
|
|
53
|
+
delayMs?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isBrowser(): boolean {
|
|
57
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function station(): string {
|
|
61
|
+
if (!isBrowser()) return 'ssr';
|
|
62
|
+
return window.location.hostname.split('.')[0] || 'unknown';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isZh(): boolean {
|
|
66
|
+
if (!isBrowser()) return false;
|
|
67
|
+
return (document.documentElement.lang || '').toLowerCase().startsWith('zh');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 剥除 URL 中的凭据类 query 参数(前缀命中 SENSITIVE_KEYS 即剥),保留其余。 */
|
|
71
|
+
function sanitizeUrl(raw: string): string {
|
|
72
|
+
try {
|
|
73
|
+
const u = new URL(raw, isBrowser() ? window.location.origin : 'https://x.invalid');
|
|
74
|
+
for (const key of Array.from(u.searchParams.keys())) {
|
|
75
|
+
if (SENSITIVE_KEYS.some((s) => key.toLowerCase() === s || key.toLowerCase().startsWith(`${s}_`))) {
|
|
76
|
+
u.searchParams.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return u.toString();
|
|
80
|
+
} catch {
|
|
81
|
+
return raw.split(/[?#]/)[0];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------- 缓冲(环形)
|
|
86
|
+
|
|
87
|
+
let bufferLoaded = false;
|
|
88
|
+
let buffer: AuthTraceEntry[] = [];
|
|
89
|
+
|
|
90
|
+
function readBuffer(): AuthTraceEntry[] {
|
|
91
|
+
if (!isBrowser()) return [];
|
|
92
|
+
if (!bufferLoaded) {
|
|
93
|
+
bufferLoaded = true;
|
|
94
|
+
try {
|
|
95
|
+
const raw = sessionStorage.getItem(BUFFER_KEY);
|
|
96
|
+
const parsed = raw ? JSON.parse(raw) : [];
|
|
97
|
+
buffer = Array.isArray(parsed) ? parsed.slice(-BUFFER_LIMIT) : [];
|
|
98
|
+
} catch {
|
|
99
|
+
buffer = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return buffer;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeBuffer(): void {
|
|
106
|
+
if (!isBrowser()) return;
|
|
107
|
+
try {
|
|
108
|
+
sessionStorage.setItem(BUFFER_KEY, JSON.stringify(buffer.slice(-BUFFER_LIMIT)));
|
|
109
|
+
} catch {
|
|
110
|
+
// 存储受限(隐私模式 / 配额)时静默降级为仅 console
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function push(ev: string, extra?: Partial<AuthTraceEntry>): AuthTraceEntry {
|
|
115
|
+
const entry: AuthTraceEntry = { t: Date.now(), ev, ...extra };
|
|
116
|
+
readBuffer().push(entry);
|
|
117
|
+
if (buffer.length > BUFFER_LIMIT) buffer = buffer.slice(-BUFFER_LIMIT);
|
|
118
|
+
writeBuffer();
|
|
119
|
+
if (isBrowser()) {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.info('[auth-trace]', entry.ev, entry.reason ?? '', entry.to ?? '');
|
|
122
|
+
}
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- debug 与水印
|
|
127
|
+
|
|
128
|
+
function readRTP(): string | null {
|
|
129
|
+
if (!isBrowser()) return null;
|
|
130
|
+
try {
|
|
131
|
+
return new URLSearchParams(window.location.search).get(RT_PARAM);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function inDebug(): boolean {
|
|
138
|
+
if (!isBrowser()) return false;
|
|
139
|
+
try {
|
|
140
|
+
if (new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE) return true;
|
|
141
|
+
return sessionStorage.getItem(DEBUG_KEY) === '1';
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let initialized = false;
|
|
148
|
+
|
|
149
|
+
/** 懒初始化:吸收跨站面包屑(?rt=)与 debug 标志;同 URL 重复调用无副作用。 */
|
|
150
|
+
function ensureInit(): void {
|
|
151
|
+
if (!isBrowser() || initialized) return;
|
|
152
|
+
initialized = true;
|
|
153
|
+
|
|
154
|
+
let urlDebug = false;
|
|
155
|
+
try {
|
|
156
|
+
urlDebug = new URLSearchParams(window.location.search).get(DEBUG_PARAM) === DEBUG_VALUE;
|
|
157
|
+
if (urlDebug) sessionStorage.setItem(DEBUG_KEY, '1');
|
|
158
|
+
} catch {
|
|
159
|
+
/* noop */
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rt = readRTP();
|
|
163
|
+
if (rt) {
|
|
164
|
+
const [prevStation, prevReason, prevTs] = rt.split('.');
|
|
165
|
+
if (prevStation && prevReason) {
|
|
166
|
+
push('handoff', {
|
|
167
|
+
from: `${prevStation}@${prevTs ? parseInt(prevTs, 36) : ''}`,
|
|
168
|
+
reason: prevReason,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
(window as any).__authTrace = {
|
|
174
|
+
version: 1,
|
|
175
|
+
dump: traceDump,
|
|
176
|
+
entries: () => readBuffer().slice(),
|
|
177
|
+
on: () => {
|
|
178
|
+
try {
|
|
179
|
+
sessionStorage.setItem(DEBUG_KEY, '1');
|
|
180
|
+
} catch {
|
|
181
|
+
/* noop */
|
|
182
|
+
}
|
|
183
|
+
return 'auth-trace debug ON(整页跳转将停在确认页;随跳转跨站透传)';
|
|
184
|
+
},
|
|
185
|
+
off: () => {
|
|
186
|
+
try {
|
|
187
|
+
sessionStorage.removeItem(DEBUG_KEY);
|
|
188
|
+
} catch {
|
|
189
|
+
/* noop */
|
|
190
|
+
}
|
|
191
|
+
return 'auth-trace debug OFF';
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
if (urlDebug || rt) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.info('[auth-trace] station', station(), urlDebug ? '(debug=on)' : '', rt ? `(handoff from ${rt.split('.')[0]})` : '');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------- 目标 URL 装饰
|
|
202
|
+
|
|
203
|
+
/** 目标 URL 追加 rt=(本站 + 原因 + 时间)与 debug 透传(debug 开启时)。 */
|
|
204
|
+
function decorateTarget(target: string, reason: string): string {
|
|
205
|
+
if (!isBrowser()) return target;
|
|
206
|
+
try {
|
|
207
|
+
const u = new URL(target, window.location.origin);
|
|
208
|
+
if (!u.searchParams.has(RT_PARAM)) {
|
|
209
|
+
u.searchParams.set(RT_PARAM, `${station()}.${reason}.${Date.now().toString(36)}`);
|
|
210
|
+
}
|
|
211
|
+
if (inDebug() && !u.searchParams.has(DEBUG_PARAM)) {
|
|
212
|
+
u.searchParams.set(DEBUG_PARAM, DEBUG_VALUE);
|
|
213
|
+
}
|
|
214
|
+
return u.toString();
|
|
215
|
+
} catch {
|
|
216
|
+
return target;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------- 跳转执行
|
|
221
|
+
|
|
222
|
+
let pending = false;
|
|
223
|
+
|
|
224
|
+
function go(target: string, assign: boolean): void {
|
|
225
|
+
pending = true;
|
|
226
|
+
if (assign) window.location.href = target;
|
|
227
|
+
else window.location.replace(target);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function removeNode(id: string): void {
|
|
231
|
+
if (!isBrowser()) return;
|
|
232
|
+
const el = document.getElementById(id);
|
|
233
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------- 层 ① 提示条
|
|
237
|
+
|
|
238
|
+
const NOTICE_ID = 'auth-trace-notice';
|
|
239
|
+
|
|
240
|
+
function reasonText(reason: string): { zh: string; en: string } {
|
|
241
|
+
const map: Record<string, { zh: string; en: string }> = {
|
|
242
|
+
'session-expired': { zh: '会话已过期', en: 'Session expired' },
|
|
243
|
+
unauthenticated: { zh: '需要登录', en: 'Sign-in required' },
|
|
244
|
+
};
|
|
245
|
+
return map[reason] ?? { zh: '即将离开本页', en: 'Leaving this page' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function showInterstitial(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
|
|
249
|
+
const zh = isZh();
|
|
250
|
+
const text = reasonText(opts.reason);
|
|
251
|
+
const delay = opts.delayMs ?? INTERSTITIAL_DELAY_MS;
|
|
252
|
+
|
|
253
|
+
removeNode(NOTICE_ID);
|
|
254
|
+
const box = document.createElement('div');
|
|
255
|
+
box.id = NOTICE_ID;
|
|
256
|
+
box.setAttribute('role', 'status');
|
|
257
|
+
box.style.cssText = [
|
|
258
|
+
'position:fixed',
|
|
259
|
+
'left:50%',
|
|
260
|
+
'bottom:24px',
|
|
261
|
+
'transform:translateX(-50%)',
|
|
262
|
+
`z-index:${Z_INDEX}`,
|
|
263
|
+
'display:flex',
|
|
264
|
+
'align-items:center',
|
|
265
|
+
'gap:12px',
|
|
266
|
+
'max-width:calc(100vw - 32px)',
|
|
267
|
+
'padding:10px 14px',
|
|
268
|
+
'border-radius:8px',
|
|
269
|
+
'background:rgba(17,24,39,.94)',
|
|
270
|
+
'color:#f9fafb',
|
|
271
|
+
'font-size:13px',
|
|
272
|
+
'line-height:1.4',
|
|
273
|
+
'font-family:system-ui,-apple-system,sans-serif',
|
|
274
|
+
'box-shadow:0 8px 24px rgba(0,0,0,.28)',
|
|
275
|
+
].join(';');
|
|
276
|
+
|
|
277
|
+
const label = document.createElement('span');
|
|
278
|
+
label.textContent = zh
|
|
279
|
+
? `${text.zh},即将前往登录(${delay / 1000} 秒后自动跳转)`
|
|
280
|
+
: `${text.en} — redirecting to sign-in in ${delay / 1000}s`;
|
|
281
|
+
box.appendChild(label);
|
|
282
|
+
|
|
283
|
+
const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
|
|
284
|
+
const b = document.createElement('button');
|
|
285
|
+
b.type = 'button';
|
|
286
|
+
b.textContent = txt;
|
|
287
|
+
b.style.cssText = [
|
|
288
|
+
'border:0',
|
|
289
|
+
'border-radius:6px',
|
|
290
|
+
'padding:5px 10px',
|
|
291
|
+
'font-size:13px',
|
|
292
|
+
'cursor:pointer',
|
|
293
|
+
primary ? 'background:#2563eb;color:#fff' : 'background:rgba(255,255,255,.14);color:#f9fafb',
|
|
294
|
+
].join(';');
|
|
295
|
+
b.addEventListener('click', onClick);
|
|
296
|
+
return b;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const timer = setTimeout(() => {
|
|
300
|
+
removeNode(NOTICE_ID);
|
|
301
|
+
go(target, !!opts.assign);
|
|
302
|
+
}, delay);
|
|
303
|
+
|
|
304
|
+
box.appendChild(mkButton(zh ? '停留' : 'Stay', false, () => {
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
removeNode(NOTICE_ID);
|
|
307
|
+
onStay();
|
|
308
|
+
}));
|
|
309
|
+
box.appendChild(mkButton(zh ? '立即前往' : 'Go now', true, () => {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
removeNode(NOTICE_ID);
|
|
312
|
+
go(target, !!opts.assign);
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
document.body.appendChild(box);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------- 层 ② 确认页
|
|
319
|
+
|
|
320
|
+
const CONFIRM_ID = 'auth-trace-confirm';
|
|
321
|
+
|
|
322
|
+
function showConfirm(target: string, opts: AuthTraceRedirectOptions, onStay: () => void): void {
|
|
323
|
+
const zh = isZh();
|
|
324
|
+
removeNode(CONFIRM_ID);
|
|
325
|
+
|
|
326
|
+
const overlay = document.createElement('div');
|
|
327
|
+
overlay.id = CONFIRM_ID;
|
|
328
|
+
overlay.style.cssText = [
|
|
329
|
+
'position:fixed',
|
|
330
|
+
'inset:0',
|
|
331
|
+
`z-index:${Z_INDEX}`,
|
|
332
|
+
'display:flex',
|
|
333
|
+
'align-items:center',
|
|
334
|
+
'justify-content:center',
|
|
335
|
+
'background:rgba(15,23,42,.55)',
|
|
336
|
+
'font-family:system-ui,-apple-system,sans-serif',
|
|
337
|
+
].join(';');
|
|
338
|
+
|
|
339
|
+
const card = document.createElement('div');
|
|
340
|
+
card.style.cssText = [
|
|
341
|
+
'width:min(560px,calc(100vw - 40px))',
|
|
342
|
+
'max-height:80vh',
|
|
343
|
+
'overflow:auto',
|
|
344
|
+
'background:#fff',
|
|
345
|
+
'color:#111827',
|
|
346
|
+
'border-radius:10px',
|
|
347
|
+
'padding:18px 20px',
|
|
348
|
+
'box-shadow:0 20px 48px rgba(0,0,0,.32)',
|
|
349
|
+
'font-size:13px',
|
|
350
|
+
].join(';');
|
|
351
|
+
|
|
352
|
+
const title = document.createElement('h2');
|
|
353
|
+
title.textContent = zh ? '整页跳转确认(authTrace debug)' : 'Full-page redirect (authTrace debug)';
|
|
354
|
+
title.style.cssText = 'margin:0 0 10px;font-size:15px;font-weight:600';
|
|
355
|
+
card.appendChild(title);
|
|
356
|
+
|
|
357
|
+
const detail = document.createElement('pre');
|
|
358
|
+
detail.textContent = [
|
|
359
|
+
`reason: ${opts.reason}`,
|
|
360
|
+
`kind: ${opts.kind ?? 'funnel'}`,
|
|
361
|
+
`from: ${sanitizeUrl(window.location.href)}`,
|
|
362
|
+
`to: ${target}`,
|
|
363
|
+
'',
|
|
364
|
+
...(readBuffer().slice(-10).map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`)),
|
|
365
|
+
].join('\n');
|
|
366
|
+
detail.style.cssText = [
|
|
367
|
+
'margin:0 0 14px',
|
|
368
|
+
'padding:10px',
|
|
369
|
+
'border-radius:6px',
|
|
370
|
+
'background:#f3f4f6',
|
|
371
|
+
'white-space:pre-wrap',
|
|
372
|
+
'word-break:break-all',
|
|
373
|
+
'font-size:12px',
|
|
374
|
+
'line-height:1.5',
|
|
375
|
+
'max-height:46vh',
|
|
376
|
+
'overflow:auto',
|
|
377
|
+
].join(';');
|
|
378
|
+
card.appendChild(detail);
|
|
379
|
+
|
|
380
|
+
const mkButton = (txt: string, primary: boolean, onClick: () => void) => {
|
|
381
|
+
const b = document.createElement('button');
|
|
382
|
+
b.type = 'button';
|
|
383
|
+
b.textContent = txt;
|
|
384
|
+
b.style.cssText = [
|
|
385
|
+
primary ? 'background:#2563eb;color:#fff' : 'background:#e5e7eb;color:#111827',
|
|
386
|
+
'border:0',
|
|
387
|
+
'border-radius:6px',
|
|
388
|
+
'padding:7px 14px',
|
|
389
|
+
'font-size:13px',
|
|
390
|
+
'cursor:pointer',
|
|
391
|
+
'margin-right:8px',
|
|
392
|
+
].join(';');
|
|
393
|
+
b.addEventListener('click', onClick);
|
|
394
|
+
return b;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
card.appendChild(mkButton(zh ? '继续前往' : 'Continue', true, () => {
|
|
398
|
+
removeNode(CONFIRM_ID);
|
|
399
|
+
go(target, !!opts.assign);
|
|
400
|
+
}));
|
|
401
|
+
card.appendChild(mkButton(zh ? '留在本页' : 'Stay', false, () => {
|
|
402
|
+
removeNode(CONFIRM_ID);
|
|
403
|
+
onStay();
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
overlay.appendChild(card);
|
|
407
|
+
document.body.appendChild(overlay);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------------------------------------------------------------- 公开 API
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 统一整页跳转出口:记 trace →(debug 时)确认页 →(interstitial 时)提示条 →
|
|
414
|
+
* 执行 location.replace/href。
|
|
415
|
+
*
|
|
416
|
+
* 重入语义:pending 期间新的 interstitial 请求被抑制(记 suppressed,防双提示/双跳);
|
|
417
|
+
* funnel 请求不被抑制(用户主动 / 流程必经,优先且覆盖 pending)。
|
|
418
|
+
*/
|
|
419
|
+
export function traceRedirect(target: string, opts: AuthTraceRedirectOptions): void {
|
|
420
|
+
if (!isBrowser()) return;
|
|
421
|
+
ensureInit();
|
|
422
|
+
const kind = opts.kind ?? 'funnel';
|
|
423
|
+
const decorated = decorateTarget(target, opts.reason);
|
|
424
|
+
const from = sanitizeUrl(window.location.href);
|
|
425
|
+
|
|
426
|
+
if (pending && kind === 'interstitial') {
|
|
427
|
+
push('suppressed', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
push('redirect', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
432
|
+
|
|
433
|
+
const onStay = () => {
|
|
434
|
+
pending = false;
|
|
435
|
+
push('stay', { reason: opts.reason, from, to: sanitizeUrl(decorated) });
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
if (inDebug()) {
|
|
439
|
+
pending = true;
|
|
440
|
+
showConfirm(decorated, opts, onStay);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (kind === 'interstitial') {
|
|
445
|
+
pending = true;
|
|
446
|
+
showInterstitial(decorated, opts, onStay);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
go(decorated, !!opts.assign);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 记一条非跳转 trace 事件(如 error 页出口、OAuth 链路里程碑)。 */
|
|
454
|
+
export function traceEvent(ev: string, extra?: { reason?: string; from?: string; to?: string }): void {
|
|
455
|
+
if (!isBrowser()) return;
|
|
456
|
+
ensureInit();
|
|
457
|
+
push(ev, extra);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 导出全部缓冲 + 环境摘要(`copy(__authTrace.dump())` 取证用)。 */
|
|
461
|
+
export function traceDump(): string {
|
|
462
|
+
if (!isBrowser()) return '';
|
|
463
|
+
ensureInit();
|
|
464
|
+
const header = [
|
|
465
|
+
`authTrace v1 @${station()}`,
|
|
466
|
+
`url: ${sanitizeUrl(window.location.href)}`,
|
|
467
|
+
`debug: ${inDebug() ? 'on' : 'off'}`,
|
|
468
|
+
`ua: ${navigator.userAgent}`,
|
|
469
|
+
`entries: ${readBuffer().length}`,
|
|
470
|
+
];
|
|
471
|
+
const lines = readBuffer().map((e) => `${new Date(e.t).toISOString()} ${e.ev} ${e.reason ?? ''} ${e.to ?? e.from ?? ''}`);
|
|
472
|
+
return [...header, '', ...lines].join('\n');
|
|
473
|
+
}
|
package/src/auth/oauth-login.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { AuthService } from './service';
|
|
|
14
14
|
import { generatePKCE } from './pkce';
|
|
15
15
|
import { getPortalUrl } from '../config';
|
|
16
16
|
import { persistOAuthClientId } from './oauth-client-id-store';
|
|
17
|
+
import { traceRedirect } from './auth-trace';
|
|
17
18
|
import type { User } from '../types';
|
|
18
19
|
|
|
19
20
|
function getAppConfig(): Record<string, string> | undefined {
|
|
@@ -157,7 +158,13 @@ export async function initiateOAuthLogin(
|
|
|
157
158
|
code_challenge_method: 'S256',
|
|
158
159
|
state,
|
|
159
160
|
});
|
|
160
|
-
|
|
161
|
+
// interstitial:未认证深链被动弹去登录 → authTrace 提示(可停留);
|
|
162
|
+
// 「停留」取消后 10s 防重入窗口过期即失效,用户刷新/再次交互可重新发起。
|
|
163
|
+
traceRedirect(`${cfg.authorizeUrl}?${params}`, {
|
|
164
|
+
reason: 'oauth-initiate',
|
|
165
|
+
kind: 'interstitial',
|
|
166
|
+
assign: true,
|
|
167
|
+
});
|
|
161
168
|
} catch {
|
|
162
169
|
const params = new URLSearchParams({
|
|
163
170
|
response_type: 'code',
|
|
@@ -166,7 +173,11 @@ export async function initiateOAuthLogin(
|
|
|
166
173
|
scope: cfg.scope,
|
|
167
174
|
state,
|
|
168
175
|
});
|
|
169
|
-
|
|
176
|
+
traceRedirect(`${cfg.authorizeUrl}?${params}`, {
|
|
177
|
+
reason: 'oauth-initiate',
|
|
178
|
+
kind: 'interstitial',
|
|
179
|
+
assign: true,
|
|
180
|
+
});
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
|
|
@@ -287,5 +298,6 @@ export async function handleOAuthCallback(): Promise<void> {
|
|
|
287
298
|
}
|
|
288
299
|
|
|
289
300
|
// Full page navigation to the target (auth gateway will route to dashboard)
|
|
290
|
-
|
|
301
|
+
// funnel:登录成功回跳(预期行为,静默直跳,assign 保持原语义);仅记 trace。
|
|
302
|
+
traceRedirect(target, { reason: 'oauth-callback', assign: true });
|
|
291
303
|
}
|
package/src/auth/roles.ts
CHANGED
|
@@ -51,6 +51,22 @@ export function buildLogoutUrl(returnUrl?: string): string {
|
|
|
51
51
|
return `${base}/?${redirect}logout=1`;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* 当前页是否已是 auth 的「登录承接面」——裸根入口(`/`、`/login`,由入口路由承接)、
|
|
56
|
+
* `/<slug>/login` 登录页、`/error` 错误页。
|
|
57
|
+
*
|
|
58
|
+
* 会话终结(onUnauthorized)时若已停在承接面,整页 replace 不但无益(目标就是本页),
|
|
59
|
+
* 还会因 buildLoginUrl 的防嵌套截断返回不带 redirect 的本页 —— 把 URL 中的回程目标
|
|
60
|
+
* 一并丢掉,且构成一次整页闪烁(F-W8b 修复②:存量过期 token 在登录页「静默弹走」)。
|
|
61
|
+
*/
|
|
62
|
+
export function isAuthLoginSurface(): boolean {
|
|
63
|
+
if (typeof window === 'undefined') return false;
|
|
64
|
+
const segs = window.location.pathname.split('/').filter(Boolean);
|
|
65
|
+
if (segs.length === 0) return true; // 裸根 `/`
|
|
66
|
+
if (segs[0] === 'login' || segs[0] === 'error') return true; // `/login`、`/error`
|
|
67
|
+
return segs.length === 2 && segs[1] === 'login'; // `/<slug>/login`
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
export function buildLoginUrl(returnUrl?: string, fromRequireAuth?: boolean): string {
|
|
55
71
|
const base = getPortalUrl('auth');
|
|
56
72
|
if (!base) return '/';
|
package/src/auth/service.ts
CHANGED
|
@@ -23,13 +23,13 @@ import {
|
|
|
23
23
|
isTokenExpired as checkExpired,
|
|
24
24
|
} from './store';
|
|
25
25
|
import { loginWithTokens } from './store';
|
|
26
|
-
import { buildLoginUrl } from './roles';
|
|
26
|
+
import { buildLoginUrl, isAuthLoginSurface } from './roles';
|
|
27
|
+
import { traceRedirect } from './auth-trace';
|
|
27
28
|
import { resolveClientIdForSession } from './oauth-client-id-store';
|
|
28
29
|
import type { User } from '../types';
|
|
29
30
|
|
|
30
31
|
// ============ Internal State ============
|
|
31
32
|
|
|
32
|
-
let redirectingToLogin = false;
|
|
33
33
|
let refreshPromise: Promise<string | null> | null = null;
|
|
34
34
|
|
|
35
35
|
// ============ OAuth PKCE Bootstrap Lock ============
|
|
@@ -259,19 +259,30 @@ export const AuthService = {
|
|
|
259
259
|
/**
|
|
260
260
|
* 刷新 access token。
|
|
261
261
|
* 防重入:同一时间只会有一个刷新请求进行中。
|
|
262
|
-
*
|
|
262
|
+
*
|
|
263
|
+
* 失败处置(onFailure,缺省 'redirect'):
|
|
264
|
+
* - 'redirect':onUnauthorized —— 清理 + 引导登录(常规路径);
|
|
265
|
+
* - 'clear' :clearSession —— 只清理不导航(调用方自控唯一导航出口,
|
|
266
|
+
* 如会话过期横幅 → error 页,避免与整页跳转双跳竞争);
|
|
267
|
+
* - 'none' :零副作用静默(尽力续期场景:如过期前 5 分钟的自动续期尝试,
|
|
268
|
+
* 失败不清理不打断——会话此刻仍有效,等真正过期再统一处置)。
|
|
263
269
|
*/
|
|
264
|
-
refreshToken: async (
|
|
270
|
+
refreshToken: async (opts?: {
|
|
271
|
+
onFailure?: 'redirect' | 'clear' | 'none';
|
|
272
|
+
}): Promise<string | null> => {
|
|
265
273
|
if (refreshPromise) return refreshPromise;
|
|
274
|
+
const onFailure = opts?.onFailure ?? 'redirect';
|
|
275
|
+
const handleFailure = () => {
|
|
276
|
+
if (onFailure === 'redirect') AuthService.onUnauthorized();
|
|
277
|
+
else if (onFailure === 'clear') AuthService.clearSession();
|
|
278
|
+
};
|
|
266
279
|
|
|
267
280
|
refreshPromise = (async () => {
|
|
268
281
|
const rt = AuthService.getRefreshToken();
|
|
269
282
|
if (!rt) {
|
|
270
283
|
// During OAuth PKCE bootstrap (no token yet), suppress redirect.
|
|
271
284
|
// RequireAuth will handle OAuth PKCE vs BFF fallback once tenantRoute resolves.
|
|
272
|
-
if (!_bootstrapLocked)
|
|
273
|
-
AuthService.onUnauthorized();
|
|
274
|
-
}
|
|
285
|
+
if (!_bootstrapLocked) handleFailure();
|
|
275
286
|
return null;
|
|
276
287
|
}
|
|
277
288
|
|
|
@@ -315,7 +326,7 @@ export const AuthService = {
|
|
|
315
326
|
return newAT || null;
|
|
316
327
|
} catch (err) {
|
|
317
328
|
console.warn('[AuthService] Token refresh failed', (err as Error)?.message || '');
|
|
318
|
-
|
|
329
|
+
handleFailure();
|
|
319
330
|
return null;
|
|
320
331
|
} finally {
|
|
321
332
|
refreshPromise = null;
|
|
@@ -328,14 +339,10 @@ export const AuthService = {
|
|
|
328
339
|
// ==================== 401/未授权处理 ====================
|
|
329
340
|
|
|
330
341
|
/**
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* 2. 重定向到登录页(防重入锁)
|
|
342
|
+
* 仅清除会话状态(store + localStorage),不做任何导航。
|
|
343
|
+
* 供 silent 刷新失败、以及需要自控导航出口的调用方使用。
|
|
334
344
|
*/
|
|
335
|
-
|
|
336
|
-
// Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
|
|
337
|
-
if (_bootstrapLocked) return;
|
|
338
|
-
// 先清除状态,不等待 logout 的异步吊销
|
|
345
|
+
clearSession: (): void => {
|
|
339
346
|
useAuthStore.getState().clearAuth();
|
|
340
347
|
if (typeof window !== 'undefined') {
|
|
341
348
|
// 清除 localStorage(同步,立即生效)
|
|
@@ -343,12 +350,32 @@ export const AuthService = {
|
|
|
343
350
|
localStorage.removeItem('access_token');
|
|
344
351
|
localStorage.removeItem('refresh_token');
|
|
345
352
|
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 处理 401 未授权响应:
|
|
357
|
+
* 1. 清除所有状态(clearSession)
|
|
358
|
+
* 2. 经 authTrace 提示(会话已过期 → 即将前往登录,可停留)后整页弹登录页
|
|
359
|
+
*
|
|
360
|
+
* 例外:已在 auth 登录承接面(裸根入口 / `/<slug>/login` / `/error`)时只清理不导航 ——
|
|
361
|
+
* 目标就是当前页,整页 replace 反而会丢掉 URL 里的 redirect 回程目标并造成闪烁
|
|
362
|
+
* (F-W8b 修复②);承接面页面对 401 有自己的收口(登录页落表单、error 页走倒计时)。
|
|
363
|
+
*
|
|
364
|
+
* 重入抑制在 authTrace 内(pending 单例;并发 401 只提示一次、只跳一次)。
|
|
365
|
+
*/
|
|
366
|
+
onUnauthorized: (): void => {
|
|
367
|
+
// Suppress redirect during OAuth PKCE bootstrap — RequireAuth handles it
|
|
368
|
+
if (_bootstrapLocked) return;
|
|
369
|
+
// 先清除状态,不等待 logout 的异步吊销
|
|
370
|
+
AuthService.clearSession();
|
|
346
371
|
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
372
|
+
if (isAuthLoginSurface()) return;
|
|
373
|
+
|
|
374
|
+
if (typeof window !== 'undefined') {
|
|
375
|
+
traceRedirect(buildLoginUrl(window.location.href, true), {
|
|
376
|
+
reason: 'session-expired',
|
|
377
|
+
kind: 'interstitial',
|
|
378
|
+
});
|
|
352
379
|
}
|
|
353
380
|
},
|
|
354
381
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { AuthService, setBootstrapLock } from '../auth/service';
|
|
3
3
|
import { buildLoginUrl } from '../auth/roles';
|
|
4
|
+
import { traceRedirect } from '../auth/auth-trace';
|
|
4
5
|
import { initiateOAuthLogin } from '../auth/oauth-login';
|
|
5
6
|
import {
|
|
6
7
|
useTenantRoute,
|
|
@@ -103,8 +104,12 @@ export function RequireAuth({
|
|
|
103
104
|
// Cross-domain or no client: redirect to auth-pages login
|
|
104
105
|
// from_requireauth=1 与 service.ts 的 onUnauthorized 同口径:auth 侧据此先做会话复检
|
|
105
106
|
// (有会话直接回跳,避免「已登录还被要求再登一次」)
|
|
107
|
+
// interstitial:未认证深链被动弹登录 → authTrace 提示(可停留);重入抑制在其内。
|
|
106
108
|
setTimeout(() => {
|
|
107
|
-
|
|
109
|
+
traceRedirect(buildLoginUrl(window.location.href, true), {
|
|
110
|
+
reason: 'unauthenticated',
|
|
111
|
+
kind: 'interstitial',
|
|
112
|
+
});
|
|
108
113
|
}, 0);
|
|
109
114
|
}, [machine.status, tenantRoute]);
|
|
110
115
|
|
|
@@ -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 {
|