@autional-cn/shared 0.1.0-rc
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/LICENSE +661 -0
- package/package.json +47 -0
- package/src/api/__tests__/entry-plane.test.ts +119 -0
- package/src/api/bff-client.ts +94 -0
- package/src/api/client.ts +174 -0
- package/src/api/entry-plane.ts +61 -0
- package/src/api/trace.ts +21 -0
- package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
- package/src/auth/__tests__/auth-machine.test.ts +157 -0
- package/src/auth/__tests__/build-login-url.test.ts +219 -0
- package/src/auth/__tests__/get-current-role.test.ts +61 -0
- package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
- package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
- package/src/auth/__tests__/session-client-id.test.ts +124 -0
- package/src/auth/__tests__/slug-from-url.test.ts +112 -0
- package/src/auth/auth-machine.ts +151 -0
- package/src/auth/auth-queries.ts +118 -0
- package/src/auth/build-tenant-urls.ts +48 -0
- package/src/auth/oauth-client-from-slug.ts +119 -0
- package/src/auth/oauth-client-id-store.ts +41 -0
- package/src/auth/oauth-login.ts +291 -0
- package/src/auth/pkce.ts +141 -0
- package/src/auth/portal-config.ts +68 -0
- package/src/auth/roles.ts +102 -0
- package/src/auth/service.ts +368 -0
- package/src/auth/slug-from-url.ts +92 -0
- package/src/auth/store.ts +226 -0
- package/src/auth/tenant-route-middleware.ts +113 -0
- package/src/auth/tenant-slug-context.tsx +9 -0
- package/src/branding/BrandingInitializer.tsx +60 -0
- package/src/branding/brand-color.ts +155 -0
- package/src/branding/branding-cache.ts +71 -0
- package/src/branding/extract-branding.ts +27 -0
- package/src/branding/index.ts +15 -0
- package/src/branding/tenant-store.ts +13 -0
- package/src/branding/types.ts +14 -0
- package/src/branding/useBranding.ts +85 -0
- package/src/components/AuditStatsOnly.tsx +65 -0
- package/src/components/OAuthCallbackPage.tsx +56 -0
- package/src/components/PortalGuard.tsx +105 -0
- package/src/components/RequireAuth.tsx +142 -0
- package/src/components/TenantIndexGuard.tsx +131 -0
- package/src/components/__tests__/RequireAuth.test.tsx +244 -0
- package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
- package/src/config/__tests__/portal-resolver.test.ts +247 -0
- package/src/config/__tests__/valid-redirect.test.ts +47 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +411 -0
- package/src/config/resolve-basename.ts +57 -0
- package/src/constants/api-paths.ts +450 -0
- package/src/generated/api.ts +12566 -0
- package/src/generated/queries.ts +12623 -0
- package/src/generated/types.ts +13888 -0
- package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
- package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
- package/src/hooks/useAuth.ts +239 -0
- package/src/hooks/useBootstrap.ts +74 -0
- package/src/hooks/useCurrentRole.ts +18 -0
- package/src/hooks/useLogout.ts +33 -0
- package/src/hooks/usePermission.ts +116 -0
- package/src/hooks/useSodMode.ts +73 -0
- package/src/index.ts +209 -0
- package/src/lib/push.ts +41 -0
- package/src/seo/dom-utils.ts +94 -0
- package/src/seo/index.ts +6 -0
- package/src/seo/jsonld.ts +29 -0
- package/src/seo/types.ts +29 -0
- package/src/seo/usePageMeta.ts +17 -0
- package/src/seo/usePageTitle.ts +14 -0
- package/src/seo/useSEO.ts +80 -0
- package/src/types/index.ts +185 -0
- package/src/utils/__tests__/mock-test.ts +15 -0
- package/src/utils/browser.ts +6 -0
- package/src/utils/case.ts +44 -0
- package/src/utils/error.ts +26 -0
- package/src/utils/format.ts +63 -0
- package/src/utils/password-transmission.test.ts +145 -0
- package/src/utils/password-transmission.ts +253 -0
- package/src/utils/response.ts +62 -0
- package/src/utils/vite.ts +4 -0
- package/src/vite-env.d.ts +18 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ============================================================
|
|
5
|
+
// L6(D8):刷新分流与登出吊销的 client_id 来源
|
|
6
|
+
// - 会话内已解析值(localStorage `oauth_client_id`,OAuth 交棒时落库)优先
|
|
7
|
+
// - env(__APP_CONFIG__.VITE_OAUTH_CLIENT_ID)兜底
|
|
8
|
+
// - 两者皆无:保持旧行为(落 identity 腿)
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
const { mockPost } = vi.hoisted(() => ({ mockPost: vi.fn() }));
|
|
12
|
+
|
|
13
|
+
vi.mock('axios', () => ({ default: { post: mockPost } }));
|
|
14
|
+
vi.mock('../../api/bff-client', () => ({ bffLogout: vi.fn(() => Promise.resolve()) }));
|
|
15
|
+
|
|
16
|
+
import { AuthService } from '../service';
|
|
17
|
+
import { useAuthStore } from '../store';
|
|
18
|
+
import { persistOAuthClientId } from '../oauth-client-id-store';
|
|
19
|
+
|
|
20
|
+
const OAUTH_REFRESH = '/oauth/api/v1/oauth/refresh';
|
|
21
|
+
const IDENTITY_REFRESH = '/identity/api/v1/auth/refresh';
|
|
22
|
+
const REVOKE = '/oauth/api/v1/oauth/revoke';
|
|
23
|
+
|
|
24
|
+
function callsTo(fragment: string) {
|
|
25
|
+
return mockPost.mock.calls.filter((c) => String(c[0]).includes(fragment));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
localStorage.clear();
|
|
31
|
+
useAuthStore.setState({ accessToken: null, refreshToken: null, isAuthenticated: false });
|
|
32
|
+
delete (window as any).__APP_CONFIG__;
|
|
33
|
+
mockPost.mockResolvedValue({ data: { access_token: 'new-at', refresh_token: 'rt-new' } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('AuthService.refreshToken 分流', () => {
|
|
41
|
+
it('opaque refresh token + 会话内 client_id → 走 OAuth 腿并带上该 client_id', async () => {
|
|
42
|
+
persistOAuthClientId('cid-from-session');
|
|
43
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
44
|
+
|
|
45
|
+
const at = await AuthService.refreshToken();
|
|
46
|
+
|
|
47
|
+
expect(at).toBe('new-at');
|
|
48
|
+
const oauthCalls = callsTo(OAUTH_REFRESH);
|
|
49
|
+
expect(oauthCalls).toHaveLength(1);
|
|
50
|
+
expect(oauthCalls[0][1]).toEqual({
|
|
51
|
+
refresh_token: 'rt-opaque-abc',
|
|
52
|
+
client_id: 'cid-from-session',
|
|
53
|
+
});
|
|
54
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('无会话值时 env 兜底(VITE_OAUTH_CLIENT_ID)', async () => {
|
|
58
|
+
(window as any).__APP_CONFIG__ = { VITE_OAUTH_CLIENT_ID: 'cid-from-env' };
|
|
59
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
60
|
+
|
|
61
|
+
await AuthService.refreshToken();
|
|
62
|
+
|
|
63
|
+
expect(callsTo(OAUTH_REFRESH)[0][1]).toEqual({
|
|
64
|
+
refresh_token: 'rt-opaque-abc',
|
|
65
|
+
client_id: 'cid-from-env',
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('两者皆无 → 落 identity 腿(形态与接线前一致)', async () => {
|
|
70
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
71
|
+
|
|
72
|
+
await AuthService.refreshToken();
|
|
73
|
+
|
|
74
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(1);
|
|
75
|
+
expect(callsTo(OAUTH_REFRESH)).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('JWT refresh token(三段式)恒走 identity 腿,即使会话内有 client_id', async () => {
|
|
79
|
+
persistOAuthClientId('cid-from-session');
|
|
80
|
+
useAuthStore.setState({ refreshToken: 'a.b.c' });
|
|
81
|
+
|
|
82
|
+
await AuthService.refreshToken();
|
|
83
|
+
|
|
84
|
+
expect(callsTo(IDENTITY_REFRESH)).toHaveLength(1);
|
|
85
|
+
expect(callsTo(OAUTH_REFRESH)).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('刷新成功后新 token 落库(updateTokens 链路)', async () => {
|
|
89
|
+
persistOAuthClientId('cid-from-session');
|
|
90
|
+
useAuthStore.setState({ refreshToken: 'rt-opaque-abc' });
|
|
91
|
+
|
|
92
|
+
await AuthService.refreshToken();
|
|
93
|
+
|
|
94
|
+
expect(useAuthStore.getState().accessToken).toBe('new-at');
|
|
95
|
+
expect(useAuthStore.getState().refreshToken).toBe('rt-new');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('AuthService.logout 吊销', () => {
|
|
100
|
+
it('access/refresh 两条吊销都带会话内 client_id', async () => {
|
|
101
|
+
persistOAuthClientId('cid-from-session');
|
|
102
|
+
useAuthStore.setState({ accessToken: 'at-1', refreshToken: 'rt-opaque-1' });
|
|
103
|
+
|
|
104
|
+
await AuthService.logout();
|
|
105
|
+
|
|
106
|
+
const revokes = callsTo(REVOKE);
|
|
107
|
+
expect(revokes).toHaveLength(2);
|
|
108
|
+
for (const call of revokes) {
|
|
109
|
+
expect(String(call[1])).toContain('client_id=cid-from-session');
|
|
110
|
+
}
|
|
111
|
+
expect(String(revokes[0][1])).toContain('token_type_hint=access_token');
|
|
112
|
+
expect(String(revokes[1][1])).toContain('token_type_hint=refresh_token');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('登出清 token 但**不清** client_id(部署级取值,非用户私有数据)', async () => {
|
|
116
|
+
persistOAuthClientId('cid-from-session');
|
|
117
|
+
useAuthStore.setState({ accessToken: 'at-1', refreshToken: 'rt-opaque-1' });
|
|
118
|
+
|
|
119
|
+
await AuthService.logout();
|
|
120
|
+
|
|
121
|
+
expect(localStorage.getItem('oauth_client_id')).toBe('cid-from-session');
|
|
122
|
+
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { extractSlugFromPath, registerNonTenantSegments, clearNonTenantSegments } from '../slug-from-url';
|
|
3
|
+
|
|
4
|
+
describe('extractSlugFromPath', () => {
|
|
5
|
+
describe('tenant slug patterns', () => {
|
|
6
|
+
it('extracts slug from /{slug}/admin', () => {
|
|
7
|
+
expect(extractSlugFromPath('/acme/admin')).toBe('acme');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('extracts slug from /{slug}/developer', () => {
|
|
11
|
+
expect(extractSlugFromPath('/foo/developer')).toBe('foo');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('extracts slug from /{slug}/security', () => {
|
|
15
|
+
expect(extractSlugFromPath('/bar/security')).toBe('bar');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('extracts slug from /{slug}/admin/dashboard (3 segments)', () => {
|
|
19
|
+
expect(extractSlugFromPath('/mycorp/admin/dashboard')).toBe('mycorp');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('extracts slug with dashes and numbers', () => {
|
|
23
|
+
expect(extractSlugFromPath('/acme-corp-123/admin')).toBe('acme-corp-123');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('extracts slug when followed by portal prefix in path with extra segments', () => {
|
|
27
|
+
expect(extractSlugFromPath('/tenant1/admin/users/123')).toBe('tenant1');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('no slug — returns undefined', () => {
|
|
32
|
+
it('returns undefined for root /', () => {
|
|
33
|
+
expect(extractSlugFromPath('/')).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns admin as slug candidate for /admin (no root portal prefix after domain migration)', () => {
|
|
37
|
+
expect(extractSlugFromPath('/admin')).toBe('admin');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns developer as slug candidate (no longer a root portal after domain migration)', () => {
|
|
41
|
+
// ADR-005: each portal has its own domain, /developer is no longer a root portal prefix
|
|
42
|
+
expect(extractSlugFromPath('/developer')).toBe('developer');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns undefined for reserved paths', () => {
|
|
46
|
+
expect(extractSlugFromPath('/oauth/callback')).toBeUndefined();
|
|
47
|
+
expect(extractSlugFromPath('/bff/identity/api/v1/me')).toBeUndefined();
|
|
48
|
+
expect(extractSlugFromPath('/api/v1/health')).toBeUndefined();
|
|
49
|
+
expect(extractSlugFromPath('/assets/logo.png')).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns admin as slug candidate for /admin/users (domain migration: no portal prefix at root)', () => {
|
|
53
|
+
// ADR-005: each portal has its own domain, so /{slug}/users pattern is expected
|
|
54
|
+
expect(extractSlugFromPath('/admin/users')).toBe('admin');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('edge cases', () => {
|
|
59
|
+
it('handles empty string', () => {
|
|
60
|
+
expect(extractSlugFromPath('')).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('handles trailing slash', () => {
|
|
64
|
+
expect(extractSlugFromPath('/acme/admin/')).toBe('acme');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns undefined for reserved-first segment even with portal second', () => {
|
|
68
|
+
// oauth is reserved, so /oauth/admin has no valid slug
|
|
69
|
+
expect(extractSlugFromPath('/oauth/admin')).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns admin as slug candidate for /admin/settings (no portal prefix at root after domain migration)', () => {
|
|
73
|
+
// ADR-005: independent domains — no root portal prefixes
|
|
74
|
+
expect(extractSlugFromPath('/admin/settings')).toBe('admin');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('registerNonTenantSegments —— 各 portal 声明自己的业务路由', () => {
|
|
78
|
+
afterEach(() => clearNonTenantSegments());
|
|
79
|
+
|
|
80
|
+
it('未注册时行为与改动前一致(向后兼容,9 个应用无需同时改)', () => {
|
|
81
|
+
expect(extractSlugFromPath('/settings')).toBe('settings');
|
|
82
|
+
expect(extractSlugFromPath('/users/123')).toBe('users');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('注册后业务路由不再被当成租户', () => {
|
|
86
|
+
registerNonTenantSegments(['settings', 'users', 'dashboard']);
|
|
87
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
88
|
+
expect(extractSlugFromPath('/users/123')).toBeUndefined();
|
|
89
|
+
expect(extractSlugFromPath('/dashboard')).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('注册后真实租户仍能识别 —— 这是本机制的关键不变量', () => {
|
|
93
|
+
registerNonTenantSegments(['settings', 'dashboard']);
|
|
94
|
+
expect(extractSlugFromPath('/acme')).toBe('acme');
|
|
95
|
+
expect(extractSlugFromPath('/acme/dashboard')).toBe('acme');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('大小写不敏感,且容忍前后斜杠与空项', () => {
|
|
99
|
+
registerNonTenantSegments([' Settings ', '/MFA-Challenge/', '', 'sso']);
|
|
100
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
101
|
+
expect(extractSlugFromPath('/mfa-challenge/x')).toBeUndefined();
|
|
102
|
+
expect(extractSlugFromPath('/sso')).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('clearNonTenantSegments 可恢复(测试之间不串味)', () => {
|
|
106
|
+
registerNonTenantSegments(['settings']);
|
|
107
|
+
expect(extractSlugFromPath('/settings')).toBeUndefined();
|
|
108
|
+
clearNonTenantSegments();
|
|
109
|
+
expect(extractSlugFromPath('/settings')).toBe('settings');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthMachine — 认证流程状态机(轻量版)
|
|
3
|
+
*
|
|
4
|
+
* 统一管理认证流程的各个阶段:
|
|
5
|
+
* checking → authenticated → bootstrap → ready
|
|
6
|
+
* → unauthenticated → redirecting
|
|
7
|
+
*
|
|
8
|
+
* 替代 RequireAuth 中的手动 useEffect 链式调用。
|
|
9
|
+
* 不依赖外部状态机库,使用 React hook + reducer 模式。
|
|
10
|
+
*
|
|
11
|
+
* @see docs/architecture/auth-next-issues-analysis.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use client';
|
|
15
|
+
|
|
16
|
+
import { useReducer, useEffect, useMemo } from 'react';
|
|
17
|
+
import { AuthService } from './service';
|
|
18
|
+
import { useBootstrap } from '../hooks/useBootstrap';
|
|
19
|
+
|
|
20
|
+
// ============ Types ============
|
|
21
|
+
|
|
22
|
+
export type AuthStatus =
|
|
23
|
+
| 'checking' // 初始态:正在检查 token
|
|
24
|
+
| 'authenticated' // token 有效
|
|
25
|
+
| 'bootstrap' // token 有效,正在加载权限/租户
|
|
26
|
+
| 'ready' // 全部就绪
|
|
27
|
+
| 'unauthenticated' // 无有效 token
|
|
28
|
+
| 'redirecting'; // 正在跳转到登录页
|
|
29
|
+
|
|
30
|
+
export interface AuthMachineState {
|
|
31
|
+
status: AuthStatus;
|
|
32
|
+
hasToken: boolean;
|
|
33
|
+
bootstrapState: ReturnType<typeof useBootstrap>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type AuthAction =
|
|
37
|
+
| { type: 'TOKEN_FOUND' }
|
|
38
|
+
| { type: 'NO_TOKEN' }
|
|
39
|
+
| { type: 'BOOTSTRAP_LOADING' }
|
|
40
|
+
| { type: 'BOOTSTRAP_READY' }
|
|
41
|
+
| { type: 'BOOTSTRAP_ERROR' }
|
|
42
|
+
| { type: 'SESSION_EXPIRED' }
|
|
43
|
+
| { type: 'REDIRECT' };
|
|
44
|
+
|
|
45
|
+
// ============ Reducer ============
|
|
46
|
+
|
|
47
|
+
function authReducer(state: AuthMachineState, action: AuthAction): AuthMachineState {
|
|
48
|
+
switch (action.type) {
|
|
49
|
+
case 'TOKEN_FOUND':
|
|
50
|
+
return { ...state, status: 'authenticated', hasToken: true };
|
|
51
|
+
|
|
52
|
+
case 'NO_TOKEN':
|
|
53
|
+
return { ...state, status: 'unauthenticated', hasToken: false };
|
|
54
|
+
|
|
55
|
+
case 'BOOTSTRAP_LOADING':
|
|
56
|
+
return state.status === 'authenticated' ? { ...state, status: 'bootstrap' } : state;
|
|
57
|
+
|
|
58
|
+
case 'BOOTSTRAP_READY':
|
|
59
|
+
return state.status === 'bootstrap'
|
|
60
|
+
? { ...state, status: 'ready' }
|
|
61
|
+
: state.status === 'authenticated'
|
|
62
|
+
? { ...state, status: 'ready' }
|
|
63
|
+
: state;
|
|
64
|
+
|
|
65
|
+
case 'BOOTSTRAP_ERROR':
|
|
66
|
+
// Bootstrap 失败仍应显示 UI(降级运行)
|
|
67
|
+
return state.status === 'bootstrap' ? { ...state, status: 'ready' } : state;
|
|
68
|
+
|
|
69
|
+
case 'SESSION_EXPIRED':
|
|
70
|
+
return { ...state, status: 'unauthenticated', hasToken: false };
|
|
71
|
+
|
|
72
|
+
case 'REDIRECT':
|
|
73
|
+
return { ...state, status: 'redirecting' };
|
|
74
|
+
|
|
75
|
+
default:
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============ Initial State ============
|
|
81
|
+
|
|
82
|
+
function createInitialState(): AuthMachineState {
|
|
83
|
+
const token = AuthService.getAccessToken();
|
|
84
|
+
return {
|
|
85
|
+
status: 'checking',
|
|
86
|
+
hasToken: !!token,
|
|
87
|
+
bootstrapState: 'idle' as const,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============ Hook ============
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 认证流程状态机 Hook。
|
|
95
|
+
*
|
|
96
|
+
* 整合 token 检查 + bootstrap 加载为统一状态机,
|
|
97
|
+
* 替代 RequireAuth + useBootstrap 的手动链式调用。
|
|
98
|
+
*
|
|
99
|
+
* 用法:
|
|
100
|
+
* ```tsx
|
|
101
|
+
* function App() {
|
|
102
|
+
* const machine = useAuthMachine();
|
|
103
|
+
* const { status, hasToken } = machine;
|
|
104
|
+
*
|
|
105
|
+
* if (status === 'checking') return <Loading />;
|
|
106
|
+
* if (status === 'unauthenticated' || status === 'redirecting') return <LoginPage />;
|
|
107
|
+
* if (status === 'ready') return <Dashboard />;
|
|
108
|
+
* return <Loading />; // authenticated / bootstrap
|
|
109
|
+
* }
|
|
110
|
+
* ```
|
|
111
|
+
*/
|
|
112
|
+
export function useAuthMachine(): AuthMachineState {
|
|
113
|
+
const [state, dispatch] = useReducer(authReducer, undefined, createInitialState);
|
|
114
|
+
const bootstrapState = useBootstrap();
|
|
115
|
+
|
|
116
|
+
// Phase 1: 检查 token
|
|
117
|
+
const hasToken = useMemo(() => {
|
|
118
|
+
const token = AuthService.getAccessToken();
|
|
119
|
+
return !!token && token !== 'undefined' && token !== 'null';
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
// Phase 2: 根据 token 和 bootstrap 状态分发事件
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (hasToken) {
|
|
125
|
+
dispatch({ type: 'TOKEN_FOUND' });
|
|
126
|
+
} else {
|
|
127
|
+
dispatch({ type: 'NO_TOKEN' });
|
|
128
|
+
}
|
|
129
|
+
}, [hasToken]);
|
|
130
|
+
|
|
131
|
+
// Phase 3: bootstrap 状态同步
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
switch (bootstrapState) {
|
|
134
|
+
case 'loading':
|
|
135
|
+
dispatch({ type: 'BOOTSTRAP_LOADING' });
|
|
136
|
+
break;
|
|
137
|
+
case 'ready':
|
|
138
|
+
dispatch({ type: 'BOOTSTRAP_READY' });
|
|
139
|
+
break;
|
|
140
|
+
case 'error':
|
|
141
|
+
dispatch({ type: 'BOOTSTRAP_ERROR' });
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}, [bootstrapState]);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...state,
|
|
148
|
+
hasToken,
|
|
149
|
+
bootstrapState,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-queries — React Query 认证数据查询
|
|
3
|
+
*
|
|
4
|
+
* 为 permissions / tenants / me 等服务端数据提供缓存语义。
|
|
5
|
+
* 替代 useBootstrap 手动管理请求/loading/error 状态的方式。
|
|
6
|
+
*
|
|
7
|
+
* 用法:
|
|
8
|
+
* const { data: permissions, isLoading } = usePermissionsQuery();
|
|
9
|
+
* const { data: tenants, isError } = useTenantsQuery();
|
|
10
|
+
*
|
|
11
|
+
* @see docs/architecture/auth-next-issues-analysis.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use client';
|
|
15
|
+
|
|
16
|
+
import { useQuery } from '@tanstack/react-query';
|
|
17
|
+
import { apiClient } from '../api/client';
|
|
18
|
+
import { extractItem } from '../utils/response';
|
|
19
|
+
|
|
20
|
+
// ============ Constants ============
|
|
21
|
+
|
|
22
|
+
/** 权限/租户缓存时间:5 分钟内不重复请求 */
|
|
23
|
+
export const AUTH_DATA_STALE_TIME = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
// ============ Query Keys ============
|
|
26
|
+
|
|
27
|
+
export const authQueryKeys = {
|
|
28
|
+
all: ['auth'] as const,
|
|
29
|
+
permissions: (tenantId?: string | null) =>
|
|
30
|
+
tenantId
|
|
31
|
+
? ([...authQueryKeys.all, 'permissions', tenantId] as const)
|
|
32
|
+
: ([...authQueryKeys.all, 'permissions'] as const),
|
|
33
|
+
tenants: (tenantId?: string | null) =>
|
|
34
|
+
tenantId
|
|
35
|
+
? ([...authQueryKeys.all, 'tenants', tenantId] as const)
|
|
36
|
+
: ([...authQueryKeys.all, 'tenants'] as const),
|
|
37
|
+
me: () => [...authQueryKeys.all, 'me'] as const,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ============ Queries ============
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 当前用户权限列表查询。
|
|
44
|
+
* 结果自动写入 AuthService(通过 onSuccess 回调),
|
|
45
|
+
* 供 usePermission hook 和权限检查使用。
|
|
46
|
+
*
|
|
47
|
+
* @param tenantId — 可选,切换租户时自动重新请求
|
|
48
|
+
*/
|
|
49
|
+
export function usePermissionsQuery(tenantId?: string | null, enabled = true) {
|
|
50
|
+
return useQuery({
|
|
51
|
+
queryKey: authQueryKeys.permissions(tenantId),
|
|
52
|
+
queryFn: async () => {
|
|
53
|
+
const headers: Record<string, string> = {};
|
|
54
|
+
if (tenantId) headers['X-Tenant-ID'] = tenantId;
|
|
55
|
+
const res = await apiClient.get('/identity/api/v1/auth/me/permissions', { headers });
|
|
56
|
+
const d = extractItem(res.data);
|
|
57
|
+
return ((Array.isArray(d) ? d : (d as { permissions?: string[] } | null)?.permissions) ||
|
|
58
|
+
[]) as string[];
|
|
59
|
+
},
|
|
60
|
+
staleTime: AUTH_DATA_STALE_TIME,
|
|
61
|
+
retry: 1,
|
|
62
|
+
retryDelay: 1000,
|
|
63
|
+
// 无 token 时禁止发起请求:未登录时该查询必然 401 → apiClient 401 拦截
|
|
64
|
+
// → refreshToken() 无 refresh token → onUnauthorized() 抢跑 buildLoginUrl,
|
|
65
|
+
// 与 RequireAuth 的 OAuth PKCE 触发形成时序竞争(跨域 admin 场景偶发 302 回登录页)。
|
|
66
|
+
// 未登录的跳转决策统一交给 RequireAuth(OAuth PKCE 或 buildLoginUrl)。
|
|
67
|
+
enabled,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 当前用户租户列表查询。
|
|
73
|
+
* 结果自动写入 AuthService。
|
|
74
|
+
*
|
|
75
|
+
* @param tenantId — 可选,切换租户时自动重新请求
|
|
76
|
+
*/
|
|
77
|
+
export function useTenantsQuery(tenantId?: string | null, enabled = true) {
|
|
78
|
+
return useQuery({
|
|
79
|
+
queryKey: authQueryKeys.tenants(tenantId),
|
|
80
|
+
queryFn: async () => {
|
|
81
|
+
const headers: Record<string, string> = {};
|
|
82
|
+
if (tenantId) headers['X-Tenant-ID'] = tenantId;
|
|
83
|
+
const res = await apiClient.get('/identity/api/v1/auth/me/tenants', { headers });
|
|
84
|
+
const d = extractItem(res.data);
|
|
85
|
+
return ((Array.isArray(d)
|
|
86
|
+
? d
|
|
87
|
+
: (d as { tenants?: Array<{ id: string; name: string; role: string }> } | null)?.tenants) ||
|
|
88
|
+
[]) as Array<{
|
|
89
|
+
id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
role: string;
|
|
92
|
+
}>;
|
|
93
|
+
},
|
|
94
|
+
staleTime: AUTH_DATA_STALE_TIME,
|
|
95
|
+
retry: 1,
|
|
96
|
+
retryDelay: 1000,
|
|
97
|
+
// 与 usePermissionsQuery 一致:无 token 时禁止发起请求,
|
|
98
|
+
// 避免 401 → onUnauthorized 抢跑 RequireAuth 的 OAuth PKCE 流程。
|
|
99
|
+
enabled,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 当前用户信息查询。
|
|
105
|
+
* 用于 OAuth 回调后获取完整用户信息。
|
|
106
|
+
*/
|
|
107
|
+
export function useMeQuery() {
|
|
108
|
+
return useQuery({
|
|
109
|
+
queryKey: authQueryKeys.me(),
|
|
110
|
+
queryFn: async () => {
|
|
111
|
+
const res = await apiClient.get('/identity/api/v1/auth/me');
|
|
112
|
+
return extractItem(res.data) ?? res.data;
|
|
113
|
+
},
|
|
114
|
+
staleTime: AUTH_DATA_STALE_TIME,
|
|
115
|
+
retry: 1,
|
|
116
|
+
retryDelay: 1000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 租户直达 URL 构造工具。
|
|
3
|
+
* 替代之前由后端返回的 loginUrl/adminUrl,前端根据自身运行时 origin 自行拼接。
|
|
4
|
+
*
|
|
5
|
+
* URL 约定(所有 Portal 统一遵循):
|
|
6
|
+
* 登录页: {portalOrigin}/{slug}/login?redirect={portalOrigin}/{slug}/admin
|
|
7
|
+
* 管理页: {portalOrigin}/{slug}/admin
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TenantUrls {
|
|
11
|
+
/** 租户登录页直达 URL(含 redirect 参数指向管理后台) */
|
|
12
|
+
loginUrl: string;
|
|
13
|
+
/** 租户管理后台直达 URL */
|
|
14
|
+
adminUrl: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 根据租户 slug 构造登录和管理后台直达 URL。
|
|
19
|
+
* 使用当前页面的 origin,适用于所有 Portal(app/user/auth 等)。
|
|
20
|
+
*
|
|
21
|
+
* @param slug - 租户 slug(即 tenant.name)
|
|
22
|
+
* @param portalOrigin - 可选,指定 portal 域名。默认用 `window.location.origin`
|
|
23
|
+
* @returns 包含 loginUrl 和 adminUrl 的对象
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const { loginUrl, adminUrl } = buildTenantUrls('acme-corp');
|
|
27
|
+
* // loginUrl = "https://app.iam.tianv.com/acme-corp/login?redirect=https://app.iam.tianv.com/acme-corp/admin"
|
|
28
|
+
* // adminUrl = "https://app.iam.tianv.com/acme-corp/admin"
|
|
29
|
+
*
|
|
30
|
+
* @example 使用 auth 域名的登录入口
|
|
31
|
+
* const { loginUrl } = buildTenantUrls('acme-corp', 'https://auth.iam.tianv.com');
|
|
32
|
+
* // loginUrl = "https://auth.iam.tianv.com/acme-corp/login?redirect=https://app.iam.tianv.com/acme-corp/admin"
|
|
33
|
+
*/
|
|
34
|
+
export function buildTenantUrls(
|
|
35
|
+
slug: string,
|
|
36
|
+
portalOrigin?: string,
|
|
37
|
+
adminOrigin?: string,
|
|
38
|
+
): TenantUrls {
|
|
39
|
+
const portal = portalOrigin || (typeof window !== 'undefined' ? window.location.origin : '/');
|
|
40
|
+
const admin = adminOrigin || portal;
|
|
41
|
+
const adminPath = `${admin}/${slug}/admin`;
|
|
42
|
+
const loginPath = `${portal}/${slug}/login`;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
loginUrl: `${loginPath}?redirect=${encodeURIComponent(adminPath)}`,
|
|
46
|
+
adminUrl: adminPath,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { PublicAuthConfigResponse } from '../generated/types';
|
|
5
|
+
import { extractSlugFromPath } from './slug-from-url';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve OAuth client ID from URL slug.
|
|
9
|
+
* Fetches public auth-config by slug and extracts oauthClientId.
|
|
10
|
+
*
|
|
11
|
+
* Returns:
|
|
12
|
+
* - { status: 'loading' } while fetching
|
|
13
|
+
* - { status: 'resolved', slug, clientId, config } on success
|
|
14
|
+
* - { status: 'not-found' } if no slug in URL, env client pinned, or no oauth client configured
|
|
15
|
+
* - { status: 'unknown-slug', slug } if by-slug is a deterministic HTTP 404 (slug 不存在)
|
|
16
|
+
*/
|
|
17
|
+
export type SlugResolution =
|
|
18
|
+
| { status: 'loading' }
|
|
19
|
+
| { status: 'resolved'; slug: string; clientId: string; config: PublicAuthConfigResponse }
|
|
20
|
+
| { status: 'not-found' }
|
|
21
|
+
| { status: 'unknown-slug'; slug: string };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 公开 auth-config 拉取三态(F-W6 门户侧闸门的判别依据):
|
|
25
|
+
* - ok:HTTP 2xx,data 为解包后的配置体;
|
|
26
|
+
* - unknown-slug:**HTTP 404** —— identity 按「租户不存在」返回
|
|
27
|
+
* (public_handler.go GetTenantAuthConfigBySlug:GetTenantBySlug 失败/空 → ErrCodeNotFound)。
|
|
28
|
+
* 已知租户但未配 OAuth client 时返回 200(client 字段为空)⇒ 不走本态。
|
|
29
|
+
* - error:网络错误 / 非 404 非 2xx / 解析失败 —— 必须 fail-open,不得据此判死。
|
|
30
|
+
*/
|
|
31
|
+
type PublicAuthConfigFetch =
|
|
32
|
+
| { kind: 'ok'; data: Record<string, any> }
|
|
33
|
+
| { kind: 'unknown-slug' }
|
|
34
|
+
| { kind: 'error' };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 拉取公开 auth-config(原始响应,snake/camel 混合未归一)。
|
|
38
|
+
* 用 raw fetch 绕过 apiClient 401 拦截器 —— 公开配置必须在 RequireAuth 跳转前解析完成。
|
|
39
|
+
*/
|
|
40
|
+
async function fetchPublicAuthConfig(slug: string): Promise<PublicAuthConfigFetch> {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(
|
|
43
|
+
`/bff/identity/api/v1/public/auth-config/by-slug/${encodeURIComponent(slug)}`,
|
|
44
|
+
);
|
|
45
|
+
if (res.status === 404) return { kind: 'unknown-slug' };
|
|
46
|
+
if (!res.ok) return { kind: 'error' };
|
|
47
|
+
const json: any = await res.json();
|
|
48
|
+
return { kind: 'ok', data: (json?.data || json || {}) as Record<string, any> };
|
|
49
|
+
} catch {
|
|
50
|
+
return { kind: 'error' };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 按 slug 实时解析 OAuth client_id(非 hook 版本)。
|
|
56
|
+
* 供登录页 from_requireauth 分支缓存未命中时回源(TASK-09 / ADR-04)。
|
|
57
|
+
* 404(未知 slug)与网络错误统一返回 null —— 调用方只关心「能不能拿到 client」。
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchOAuthClientIdBySlug(slug: string): Promise<string | null> {
|
|
60
|
+
const fetched = await fetchPublicAuthConfig(slug);
|
|
61
|
+
if (fetched.kind !== 'ok') return null;
|
|
62
|
+
return fetched.data.oauthClientId || fetched.data.oauth_client_id || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useOAuthClientIdFromUrl(): SlugResolution {
|
|
66
|
+
const [resolution, setResolution] = useState<SlugResolution>({ status: 'loading' });
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (typeof window === 'undefined') {
|
|
70
|
+
setResolution({ status: 'not-found' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const slug = extractSlugFromPath(window.location.pathname);
|
|
75
|
+
if (!slug) {
|
|
76
|
+
setResolution({ status: 'not-found' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 若当前应用已通过 VITE_OAUTH_CLIENT_ID 固定了 OAuth client(如 admin-console),
|
|
81
|
+
// 无需再按 URL slug 解析——避免把 /users、/roles 等应用路径误当租户 slug 发起 404 请求。
|
|
82
|
+
const cfg = (window as any).__APP_CONFIG__;
|
|
83
|
+
if (cfg?.VITE_OAUTH_CLIENT_ID) {
|
|
84
|
+
setResolution({ status: 'not-found' });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
void fetchPublicAuthConfig(slug).then((fetched) => {
|
|
90
|
+
if (cancelled) return;
|
|
91
|
+
if (fetched.kind === 'unknown-slug') {
|
|
92
|
+
setResolution({ status: 'unknown-slug', slug });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (fetched.kind !== 'ok') {
|
|
96
|
+
setResolution({ status: 'not-found' });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Raw fetch doesn't camelCase keys — handle both snake_case and camelCase
|
|
100
|
+
const clientId = fetched.data.oauthClientId || fetched.data.oauth_client_id;
|
|
101
|
+
if (clientId) {
|
|
102
|
+
setResolution({
|
|
103
|
+
status: 'resolved',
|
|
104
|
+
slug,
|
|
105
|
+
clientId,
|
|
106
|
+
config: fetched.data as PublicAuthConfigResponse,
|
|
107
|
+
});
|
|
108
|
+
} else {
|
|
109
|
+
setResolution({ status: 'not-found' });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
return resolution;
|
|
119
|
+
}
|