@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.
Files changed (81) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +47 -0
  3. package/src/api/__tests__/entry-plane.test.ts +119 -0
  4. package/src/api/bff-client.ts +94 -0
  5. package/src/api/client.ts +174 -0
  6. package/src/api/entry-plane.ts +61 -0
  7. package/src/api/trace.ts +21 -0
  8. package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
  9. package/src/auth/__tests__/auth-machine.test.ts +157 -0
  10. package/src/auth/__tests__/build-login-url.test.ts +219 -0
  11. package/src/auth/__tests__/get-current-role.test.ts +61 -0
  12. package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
  13. package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
  14. package/src/auth/__tests__/session-client-id.test.ts +124 -0
  15. package/src/auth/__tests__/slug-from-url.test.ts +112 -0
  16. package/src/auth/auth-machine.ts +151 -0
  17. package/src/auth/auth-queries.ts +118 -0
  18. package/src/auth/build-tenant-urls.ts +48 -0
  19. package/src/auth/oauth-client-from-slug.ts +119 -0
  20. package/src/auth/oauth-client-id-store.ts +41 -0
  21. package/src/auth/oauth-login.ts +291 -0
  22. package/src/auth/pkce.ts +141 -0
  23. package/src/auth/portal-config.ts +68 -0
  24. package/src/auth/roles.ts +102 -0
  25. package/src/auth/service.ts +368 -0
  26. package/src/auth/slug-from-url.ts +92 -0
  27. package/src/auth/store.ts +226 -0
  28. package/src/auth/tenant-route-middleware.ts +113 -0
  29. package/src/auth/tenant-slug-context.tsx +9 -0
  30. package/src/branding/BrandingInitializer.tsx +60 -0
  31. package/src/branding/brand-color.ts +155 -0
  32. package/src/branding/branding-cache.ts +71 -0
  33. package/src/branding/extract-branding.ts +27 -0
  34. package/src/branding/index.ts +15 -0
  35. package/src/branding/tenant-store.ts +13 -0
  36. package/src/branding/types.ts +14 -0
  37. package/src/branding/useBranding.ts +85 -0
  38. package/src/components/AuditStatsOnly.tsx +65 -0
  39. package/src/components/OAuthCallbackPage.tsx +56 -0
  40. package/src/components/PortalGuard.tsx +105 -0
  41. package/src/components/RequireAuth.tsx +142 -0
  42. package/src/components/TenantIndexGuard.tsx +131 -0
  43. package/src/components/__tests__/RequireAuth.test.tsx +244 -0
  44. package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
  45. package/src/config/__tests__/portal-resolver.test.ts +247 -0
  46. package/src/config/__tests__/valid-redirect.test.ts +47 -0
  47. package/src/config/constants.ts +27 -0
  48. package/src/config/index.ts +411 -0
  49. package/src/config/resolve-basename.ts +57 -0
  50. package/src/constants/api-paths.ts +450 -0
  51. package/src/generated/api.ts +12566 -0
  52. package/src/generated/queries.ts +12623 -0
  53. package/src/generated/types.ts +13888 -0
  54. package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
  55. package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
  56. package/src/hooks/useAuth.ts +239 -0
  57. package/src/hooks/useBootstrap.ts +74 -0
  58. package/src/hooks/useCurrentRole.ts +18 -0
  59. package/src/hooks/useLogout.ts +33 -0
  60. package/src/hooks/usePermission.ts +116 -0
  61. package/src/hooks/useSodMode.ts +73 -0
  62. package/src/index.ts +209 -0
  63. package/src/lib/push.ts +41 -0
  64. package/src/seo/dom-utils.ts +94 -0
  65. package/src/seo/index.ts +6 -0
  66. package/src/seo/jsonld.ts +29 -0
  67. package/src/seo/types.ts +29 -0
  68. package/src/seo/usePageMeta.ts +17 -0
  69. package/src/seo/usePageTitle.ts +14 -0
  70. package/src/seo/useSEO.ts +80 -0
  71. package/src/types/index.ts +185 -0
  72. package/src/utils/__tests__/mock-test.ts +15 -0
  73. package/src/utils/browser.ts +6 -0
  74. package/src/utils/case.ts +44 -0
  75. package/src/utils/error.ts +26 -0
  76. package/src/utils/format.ts +63 -0
  77. package/src/utils/password-transmission.test.ts +145 -0
  78. package/src/utils/password-transmission.ts +253 -0
  79. package/src/utils/response.ts +62 -0
  80. package/src/utils/vite.ts +4 -0
  81. package/src/vite-env.d.ts +18 -0
@@ -0,0 +1,91 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
+ import { renderHook, cleanup } from '@testing-library/react';
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
+
7
+ // ============================================================
8
+ // F-W5b 回归锁:useBootstrap 无会话时必须 'idle',不得 'loading'
9
+ // —— platform-console LayoutWrapper 以 bootstrap==='loading' 挡 Outlet,
10
+ // 而 RequireAuth(唯一登录交棒点)在 Outlet 内 ⇒ 无会话冷启动永停 Spin。
11
+ // ============================================================
12
+
13
+ const { mockQueries } = vi.hoisted(() => ({
14
+ mockQueries: {
15
+ perms: { data: undefined as unknown, isError: false },
16
+ tenants: { data: undefined as unknown, isError: false },
17
+ },
18
+ }));
19
+
20
+ vi.mock('../../auth/auth-queries', () => ({
21
+ usePermissionsQuery: () => mockQueries.perms,
22
+ useTenantsQuery: () => mockQueries.tenants,
23
+ }));
24
+
25
+ import { useBootstrap } from '../useBootstrap';
26
+ import { useAuthStore } from '../../auth/store';
27
+
28
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
29
+
30
+ function wrapper({ children }: { children: React.ReactNode }) {
31
+ return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
32
+ }
33
+
34
+ function resetAuthStore(): void {
35
+ useAuthStore.setState({
36
+ user: null,
37
+ accessToken: null,
38
+ refreshToken: null,
39
+ tenants: [],
40
+ currentTenantId: null,
41
+ permissions: [],
42
+ isAuthenticated: false,
43
+ });
44
+ window.localStorage.clear();
45
+ }
46
+
47
+ describe('useBootstrap(F-W5b 无会话语义)', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ resetAuthStore();
51
+ mockQueries.perms = { data: undefined, isError: false };
52
+ mockQueries.tenants = { data: undefined, isError: false };
53
+ });
54
+
55
+ afterEach(() => {
56
+ cleanup();
57
+ resetAuthStore();
58
+ });
59
+
60
+ it('无 token → idle(回归锁:返回 loading 会让 Layout 消费者永停 Spin)', () => {
61
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
62
+ expect(result.current).toBe('idle');
63
+ expect(result.current).not.toBe('loading');
64
+ });
65
+
66
+ it('无 token 且 store 残留数据 → 仍 idle(登出/切换租户后的残态不得冒充 ready)', () => {
67
+ useAuthStore.setState({
68
+ accessToken: null,
69
+ permissions: ['user.read'],
70
+ tenants: [{ id: 't1', name: 'demo', role: 'member' }],
71
+ });
72
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
73
+ expect(result.current).toBe('idle');
74
+ });
75
+
76
+ it('有 token + store 已缓存权限与租户 → ready', () => {
77
+ useAuthStore.setState({
78
+ accessToken: 'token-xyz',
79
+ permissions: ['user.read'],
80
+ tenants: [{ id: 't1', name: 'demo', role: 'member' }],
81
+ });
82
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
83
+ expect(result.current).toBe('ready');
84
+ });
85
+
86
+ it('有 token 但查询尚未到达 → loading(保持既有等待语义)', () => {
87
+ useAuthStore.setState({ accessToken: 'token-xyz' });
88
+ const { result } = renderHook(() => useBootstrap(), { wrapper });
89
+ expect(result.current).toBe('loading');
90
+ });
91
+ });
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+ import { renderHook, act, cleanup } from '@testing-library/react';
4
+ import { useAuthStore } from '../../auth/store';
5
+ import { useCurrentRole } from '../useCurrentRole';
6
+
7
+ const TENANTS = [
8
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
9
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
10
+ ];
11
+
12
+ /** 重置 store + localStorage,避免跨用例污染(AC-002 约定) */
13
+ function resetAuthStore(): void {
14
+ useAuthStore.setState({
15
+ user: null,
16
+ accessToken: null,
17
+ refreshToken: null,
18
+ tenants: [],
19
+ currentTenantId: null,
20
+ permissions: [],
21
+ isAuthenticated: false,
22
+ });
23
+ window.localStorage.clear();
24
+ }
25
+
26
+ describe('useCurrentRole (AC-002 响应式,zustand selector 订阅 store 本身)', () => {
27
+ beforeEach(() => {
28
+ resetAuthStore();
29
+ });
30
+
31
+ afterEach(() => {
32
+ cleanup();
33
+ resetAuthStore();
34
+ });
35
+
36
+ it('初始空 store → null', () => {
37
+ const { result } = renderHook(() => useCurrentRole());
38
+ expect(result.current).toBeNull();
39
+ });
40
+
41
+ it('渲染后直写 store(setState)→ re-render + 新值', () => {
42
+ const { result } = renderHook(() => useCurrentRole());
43
+ expect(result.current).toBeNull();
44
+
45
+ act(() => {
46
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
47
+ });
48
+ expect(result.current).toBe('admin');
49
+
50
+ act(() => {
51
+ useAuthStore.setState({ currentTenantId: 'tenant-b' });
52
+ });
53
+ expect(result.current).toBe('member');
54
+ });
55
+
56
+ it('updateTenants / updateCurrentTenant action 后重渲染新值', () => {
57
+ const { result } = renderHook(() => useCurrentRole());
58
+ expect(result.current).toBeNull();
59
+
60
+ act(() => {
61
+ useAuthStore.getState().setTenants(TENANTS);
62
+ });
63
+ expect(result.current).toBeNull(); // 有租户但未选 → 不猜测
64
+
65
+ act(() => {
66
+ useAuthStore.getState().setCurrentTenant('tenant-b');
67
+ });
68
+ expect(result.current).toBe('member');
69
+
70
+ act(() => {
71
+ useAuthStore.getState().switchTenant('tenant-a');
72
+ });
73
+ expect(result.current).toBe('admin');
74
+ });
75
+
76
+ it('logout 清空(tenants [] / currentTenantId null)→ null', () => {
77
+ const { result } = renderHook(() => useCurrentRole());
78
+
79
+ act(() => {
80
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
81
+ });
82
+ expect(result.current).toBe('admin');
83
+
84
+ act(() => {
85
+ useAuthStore.getState().clearAuth();
86
+ });
87
+ expect(result.current).toBeNull();
88
+ });
89
+
90
+ it('selector 原语稳定:无关字段变更不触发 re-render(Object.is 比较)', () => {
91
+ let renderCount = 0;
92
+ const { result } = renderHook(() => {
93
+ renderCount++;
94
+ return useCurrentRole();
95
+ });
96
+
97
+ act(() => {
98
+ useAuthStore.setState({ tenants: TENANTS, currentTenantId: 'tenant-a' });
99
+ });
100
+ expect(result.current).toBe('admin');
101
+ const afterRoleSet = renderCount;
102
+
103
+ // 无关字段变更(permissions)→ selector 结果相同 → 不 re-render
104
+ act(() => {
105
+ useAuthStore.setState({ permissions: ['tenant:user:read'] });
106
+ });
107
+ expect(result.current).toBe('admin');
108
+ expect(renderCount).toBe(afterRoleSet);
109
+
110
+ // 相同 role、不同数组引用 → 返回值仍是 string 原语 → 不 re-render
111
+ act(() => {
112
+ useAuthStore.setState({
113
+ tenants: [
114
+ { id: 'tenant-a', name: 'Tenant A', role: 'admin' },
115
+ { id: 'tenant-b', name: 'Tenant B', role: 'member' },
116
+ ],
117
+ });
118
+ });
119
+ expect(result.current).toBe('admin');
120
+ expect(renderCount).toBe(afterRoleSet);
121
+ });
122
+ });
@@ -0,0 +1,239 @@
1
+ /**
2
+ * useAuth — 认证状态 React Hook
3
+ *
4
+ * 基于 useSyncExternalStore 订阅 AuthService 事件,
5
+ * 实现零额外渲染的认证状态响应。
6
+ *
7
+ * 替代以下模式的直接 useAuthStore 使用:
8
+ * const { isAuthenticated, user, permissions } = useAuthStore();
9
+ * 改为:
10
+ * const { isAuthenticated, user, permissions } = useAuth();
11
+ *
12
+ * useAuthActions() 替代直接调用 AuthService 静态方法:
13
+ * const { login, logout, setCurrentTenant } = useAuthActions();
14
+ * await logout();
15
+ *
16
+ * @see docs/architecture/auth-service-migration-plan.md
17
+ */
18
+
19
+ 'use client';
20
+
21
+ import { useSyncExternalStore, useCallback } from 'react';
22
+ import { AuthService } from '../auth/service';
23
+ import type { User } from '../types';
24
+
25
+ // ============ Types ============
26
+
27
+ export interface AuthSnapshot {
28
+ isAuthenticated: boolean;
29
+ user: User | null;
30
+ userId: string | null;
31
+ accessToken: string | null;
32
+ currentTenantId: string | null;
33
+ permissions: string[];
34
+ tenants: Array<{ id: string; name: string; role: string }>;
35
+ }
36
+
37
+ export interface AuthActions {
38
+ /** 跳转到 OAuth 登录页或 auth-pages */
39
+ login: (params?: { clientId?: string; redirectUri?: string }) => Promise<void>;
40
+ /** 统一登出(清除所有状态 + 跳转登录页) */
41
+ logout: () => Promise<void>;
42
+ /** 设置当前租户 */
43
+ setCurrentTenant: (tenantId: string) => void;
44
+ /** 更新用户信息 */
45
+ updateUser: (user: Partial<User>) => void;
46
+ /** 设置权限列表 */
47
+ updatePermissions: (permissions: string[]) => void;
48
+ /** 设置租户列表 */
49
+ updateTenants: (tenants: Array<{ id: string; name: string; role: string }>) => void;
50
+ }
51
+
52
+ // ============ Snapshot with Change Detection ============
53
+
54
+ /** 浅比较两个快照 — 只有实际变化时才返回新引用,避免不必要 re-render */
55
+ function shallowEqual(a: AuthSnapshot, b: AuthSnapshot): boolean {
56
+ if (a.isAuthenticated !== b.isAuthenticated) return false;
57
+ if (a.userId !== b.userId) return false;
58
+ if (a.accessToken !== b.accessToken) return false;
59
+ if (a.currentTenantId !== b.currentTenantId) return false;
60
+ if (a.user?.username !== b.user?.username) return false;
61
+ if (a.user?.email !== b.user?.email) return false;
62
+ if (a.permissions.length !== b.permissions.length) return false;
63
+ if (a.tenants.length !== b.tenants.length) return false;
64
+ // 简化的数组比较:内容变更时下个事件循环会触发重新计算
65
+ return true;
66
+ }
67
+
68
+ let lastSnapshot: AuthSnapshot | null = null;
69
+
70
+ /** 所有事件触发的回调(统一入口,减少事件注册数量) */
71
+ const AUTH_EVENTS = [
72
+ 'auth:login',
73
+ 'auth:logout',
74
+ 'auth:user-updated',
75
+ 'auth:tokens-refreshed',
76
+ 'auth:tenant-changed',
77
+ 'auth:tenants-updated',
78
+ 'auth:permissions-updated',
79
+ ] as const;
80
+
81
+ function buildSnapshot(): AuthSnapshot {
82
+ const user = AuthService.getUser();
83
+ return {
84
+ isAuthenticated: AuthService.isAuthenticated(),
85
+ user,
86
+ userId: user?.id || null,
87
+ accessToken: AuthService.getAccessToken(),
88
+ currentTenantId: AuthService.getCurrentTenantId(),
89
+ permissions: AuthService.getPermissions(),
90
+ tenants: AuthService.getTenants(),
91
+ };
92
+ }
93
+
94
+ function getSnapshot(): AuthSnapshot {
95
+ const now = buildSnapshot();
96
+ if (lastSnapshot && shallowEqual(lastSnapshot, now)) {
97
+ return lastSnapshot;
98
+ }
99
+ lastSnapshot = now;
100
+ return now;
101
+ }
102
+
103
+ function subscribeToAuth(callback: () => void): () => void {
104
+ const unsubs = AUTH_EVENTS.map((event) => AuthService.subscribe(event, callback));
105
+ return () => unsubs.forEach((u) => u());
106
+ }
107
+
108
+ // ============ useAuth ============
109
+
110
+ /**
111
+ * 响应式认证状态 Hook。
112
+ * 返回完整认证快照,含用户、token、权限、租户信息。
113
+ * 内部使用 shallowEqual 缓存,只有实际变化时才触发 re-render。
114
+ *
115
+ * 用法:
116
+ * ```tsx
117
+ * const { isAuthenticated, user, permissions, currentTenantId } = useAuth();
118
+ * ```
119
+ */
120
+ export function useAuth(): AuthSnapshot {
121
+ const snapshot = useSyncExternalStore(subscribeToAuth, getSnapshot);
122
+ return snapshot;
123
+ }
124
+
125
+ // ============ Selector Hooks ============
126
+
127
+ /**
128
+ * 是否已认证(只读,仅当此值变化时 re-render)
129
+ */
130
+ export function useIsAuthenticated(): boolean {
131
+ const { isAuthenticated } = useAuth();
132
+ return isAuthenticated;
133
+ }
134
+
135
+ /**
136
+ * 当前用户(只读,仅当此值变化时 re-render)
137
+ */
138
+ export function useUser(): User | null {
139
+ const { user } = useAuth();
140
+ return user;
141
+ }
142
+
143
+ /**
144
+ * 当前用户 ID(只读,仅当此值变化时 re-render)
145
+ */
146
+ export function useUserId(): string | null {
147
+ const { userId } = useAuth();
148
+ return userId;
149
+ }
150
+
151
+ /**
152
+ * 当前 access token(只读,仅当此值变化时 re-render)
153
+ */
154
+ export function useAccessToken(): string | null {
155
+ const { accessToken } = useAuth();
156
+ return accessToken;
157
+ }
158
+
159
+ /**
160
+ * 当前权限列表(只读,仅当此值变化时 re-render)
161
+ */
162
+ export function useAuthPermissions(): string[] {
163
+ const { permissions } = useAuth();
164
+ return permissions;
165
+ }
166
+
167
+ /**
168
+ * 当前租户 ID(只读,仅当此值变化时 re-render)
169
+ */
170
+ export function useCurrentTenantId(): string | null {
171
+ const { currentTenantId } = useAuth();
172
+ return currentTenantId;
173
+ }
174
+
175
+ /**
176
+ * 租户列表(只读,仅当此值变化时 re-render)
177
+ */
178
+ export function useTenants(): Array<{ id: string; name: string; role: string }> {
179
+ const { tenants } = useAuth();
180
+ return tenants;
181
+ }
182
+
183
+ // ============ useAuthActions ============
184
+
185
+ /**
186
+ * 认证操作 Hook。
187
+ * 提供 login/logout/setCurrentTenant/update* 等操作,
188
+ * 替代组件中直接调用 AuthService 静态方法。
189
+ *
190
+ * 用法:
191
+ * ```tsx
192
+ * const { logout, setCurrentTenant } = useAuthActions();
193
+ * await logout();
194
+ * setCurrentTenant('new-tenant-id');
195
+ * ```
196
+ */
197
+ export function useAuthActions(): AuthActions {
198
+ const login = useCallback(
199
+ async (params?: { clientId?: string; redirectUri?: string }): Promise<void> => {
200
+ if (params?.clientId) {
201
+ const { initiateOAuthLogin } = await import('../auth/oauth-login');
202
+ return initiateOAuthLogin(params.clientId, params.redirectUri);
203
+ }
204
+ const { buildLoginUrl } = await import('../auth/roles');
205
+ window.location.href = buildLoginUrl(window.location.href);
206
+ },
207
+ [],
208
+ );
209
+
210
+ const logout = useCallback(async (): Promise<void> => {
211
+ // 在 clearAuth 前捕获 URL,避免时序竞争(与 useLogout 一致)
212
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
213
+ await AuthService.logout();
214
+ const { buildLogoutUrl } = await import('../auth/roles');
215
+ // 传当前 URL 作为 returnUrl,保留租户 slug + 登出意图标记(与 useLogout 一致)
216
+ window.location.href = buildLogoutUrl(currentUrl);
217
+ }, []);
218
+
219
+ const setCurrentTenant = useCallback((tenantId: string): void => {
220
+ AuthService.updateCurrentTenant(tenantId);
221
+ }, []);
222
+
223
+ const updateUser = useCallback((user: Partial<User>): void => {
224
+ AuthService.updateUser(user);
225
+ }, []);
226
+
227
+ const updatePermissions = useCallback((permissions: string[]): void => {
228
+ AuthService.updatePermissions(permissions);
229
+ }, []);
230
+
231
+ const updateTenants = useCallback(
232
+ (tenants: Array<{ id: string; name: string; role: string }>): void => {
233
+ AuthService.updateTenants(tenants);
234
+ },
235
+ [],
236
+ );
237
+
238
+ return { login, logout, setCurrentTenant, updateUser, updatePermissions, updateTenants };
239
+ }
@@ -0,0 +1,74 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { useAuthStore } from '../auth/store';
5
+ import { AuthService } from '../auth/service';
6
+ import { usePermissionsQuery, useTenantsQuery } from '../auth/auth-queries';
7
+
8
+ export type BootstrapState = 'idle' | 'loading' | 'ready' | 'error';
9
+
10
+ /**
11
+ * 应用启动数据加载 Hook。
12
+ *
13
+ * 使用 React Query 缓存权限和租户数据(5 分钟 staleTime),
14
+ * 避免重复请求。首次加载时自动写入 AuthService。
15
+ *
16
+ * 返回 BootstrapState 用于 Portal Layout 展示加载状态。
17
+ */
18
+ export function useBootstrap(): BootstrapState {
19
+ const currentTenantId = useAuthStore((s) => s.currentTenantId);
20
+ const permissionsStore = useAuthStore((s) => s.permissions);
21
+ const tenantsStore = useAuthStore((s) => s.tenants);
22
+
23
+ // 无有效 token 时不发起权限/租户查询。
24
+ // 未登录时这些查询必然 401 → apiClient 401 拦截 → refreshToken() 无 refresh token
25
+ // → onUnauthorized() 立即 buildLoginUrl 302,抢跑 RequireAuth 的 OAuth PKCE 决策
26
+ // (跨域 admin 场景偶发:本应跳 authorize,实际却 302 回 auth 登录页)。
27
+ // 未登录的跳转决策统一交给 RequireAuth(OAuth PKCE 或 buildLoginUrl)。
28
+ const rawToken = AuthService.getAccessToken();
29
+ const hasToken = !!rawToken && rawToken !== 'undefined' && rawToken !== 'null';
30
+
31
+ // React Query: 5 分钟缓存,自动管理 loading/error
32
+ // key 中包含 currentTenantId,切换租户时自动重新请求
33
+ const permsQuery = usePermissionsQuery(currentTenantId, hasToken);
34
+ const tentsQuery = useTenantsQuery(currentTenantId, hasToken);
35
+
36
+ // 同步 React Query 结果到 AuthService(写入 store 触发 persist)
37
+ useEffect(() => {
38
+ if (permsQuery.data && permsQuery.data.length > 0) {
39
+ AuthService.updatePermissions(permsQuery.data);
40
+ }
41
+ }, [permsQuery.data]);
42
+
43
+ useEffect(() => {
44
+ if (tentsQuery.data && tentsQuery.data.length > 0) {
45
+ AuthService.updateTenants(tentsQuery.data);
46
+ }
47
+ }, [tentsQuery.data]);
48
+
49
+ // 无会话 = 无数据可引导:返回 'idle',登录交棒由 RequireAuth/守卫决策(见上)。
50
+ // 不得返回 'loading':Layout 消费者(platform-console)以 bootstrap==='loading'
51
+ // 挡 Outlet,而 RequireAuth 在 Outlet 里 ⇒ 无会话冷启动永停 Spin、守卫永不挂载(F-W5b)。
52
+ if (!hasToken) {
53
+ return 'idle';
54
+ }
55
+
56
+ // 已缓存数据 → 立即 ready
57
+ if (permissionsStore.length > 0 && tenantsStore.length > 0) {
58
+ return 'ready';
59
+ }
60
+
61
+ // React Query 数据到达(即使是 stale)
62
+ if (permsQuery.data || tentsQuery.data) {
63
+ return 'ready';
64
+ }
65
+
66
+ // 两个请求都失败 — keep reporting loading to prevent re-render loops
67
+ // (OAuth PKCE or BFF redirect will resolve the page before error matters)
68
+ if (permsQuery.isError && tentsQuery.isError) {
69
+ return 'loading';
70
+ }
71
+
72
+ // 等待中
73
+ return 'loading';
74
+ }
@@ -0,0 +1,18 @@
1
+ 'use client';
2
+
3
+ import { useAuthStore } from '../auth/store';
4
+
5
+ /**
6
+ * 响应式当前角色 hook。
7
+ * 用 zustand selector 订阅 store 本身 —— loadAuthExtras 直写 store action
8
+ * 不发 AuthService 事件,仅订阅事件会在密码登录路径漏刷新(ADR-002)。
9
+ * selector 返回 string 原语 → Object.is 比较引用稳定,不会无限重渲染。
10
+ * 前端角色仅用于 UX 呈现;安全判断由后端 rbac-backed 授权强制(AC-008)。
11
+ */
12
+ export function useCurrentRole(): string | null {
13
+ return useAuthStore((s) => {
14
+ if (!s.tenants || s.tenants.length === 0) return null;
15
+ if (!s.currentTenantId) return null;
16
+ return s.tenants.find((t) => t.id === s.currentTenantId)?.role ?? null;
17
+ });
18
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * 统一的登出 Hook
3
+ * 所有 portal 必须使用此 hook 处理登出,确保清理逻辑一致
4
+ * 委托给 AuthService.logout() 统一实现
5
+ */
6
+
7
+ import { useCallback } from 'react';
8
+ import { buildLogoutUrl } from '../auth/roles';
9
+
10
+ /**
11
+ * 统一的登出 Hook
12
+ * 委托 AuthService.logout() 执行清理(清除内存状态、localStorage、cookie、token 吊销),
13
+ * 然后跳转到登录页。
14
+ */
15
+ export function useLogout() {
16
+ return useCallback(() => {
17
+ const performLogout = async () => {
18
+ // 在 clearAuth/跳转前捕获当前 URL,避免时序竞争:
19
+ // AuthService.logout() 的 clearAuth 会触发 store 变化,
20
+ // 页面可能因此先导航到 '/',导致后续读到的 window.location.href 丢失租户 slug
21
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
22
+
23
+ // AuthService.logout() 统一处理:clearAuth + LS 清理 + cookie 清理 + token 吊销
24
+ const { AuthService } = await import('../auth/service');
25
+ await AuthService.logout();
26
+
27
+ // buildLogoutUrl = auth 域裸根 + `logout=1` 登出意图标记:入口路由据此先
28
+ // 终结会话再落 brand,而不是把带会话的回程当普通深链直送登录页(会静默重登)。
29
+ window.location.href = buildLogoutUrl(currentUrl);
30
+ };
31
+ performLogout();
32
+ }, []);
33
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 权限检查 Hook
3
+ * 基于 effective_permissions 数组进行前端权限控制
4
+ * 通过 zustand selector 订阅 store 本身实现响应式更新(ADR-002)——
5
+ * loadAuthExtras 直写 store action 不发 AuthService 事件,仅订阅事件会漏刷新。
6
+ */
7
+
8
+ import { useAuthStore } from '../auth/store';
9
+ import { useCurrentRole } from './useCurrentRole';
10
+
11
+ export interface PermissionResult {
12
+ can: (permission: string) => boolean;
13
+ canAny: (...permissions: string[]) => boolean;
14
+ canAll: (...permissions: string[]) => boolean;
15
+ isSuperAdmin: boolean;
16
+ isTenantAdmin: boolean;
17
+ isAuditor: boolean;
18
+ isDeveloper: boolean;
19
+ isEndUser: boolean;
20
+ }
21
+
22
+ const PERMISSION_MAP: Record<string, string[]> = {
23
+ 'user:create': ['tenant:user:read'],
24
+ 'user:read': ['tenant:user:read', 'tenant:dashboard:read', 'self:profile:read'],
25
+ 'user:update': ['tenant:user:read'],
26
+ 'user:delete': ['tenant:user:read'],
27
+ 'role:create': ['tenant:role:read'],
28
+ 'role:read': ['tenant:role:read'],
29
+ 'role:update': ['tenant:role:read'],
30
+ 'role:delete': ['tenant:role:read'],
31
+ 'audit:read': ['tenant:audit:read', 'tenant:compliance:read'],
32
+ 'profile:read': ['tenant:profile:read'],
33
+ 'profile:write': ['tenant:profile:read'],
34
+ 'profile:archive': ['tenant:profile:read'],
35
+ 'profile:export': ['tenant:profile:read', 'tenant:compliance:read'],
36
+ 'profile:delete': ['tenant:profile:read'],
37
+ 'profile:manage': ['tenant:profile:read'],
38
+ 'system:config': [
39
+ 'tenant:permission:read',
40
+ 'tenant:session:read',
41
+ 'tenant:secret:manage',
42
+ 'tenant:department:read',
43
+ 'tenant:member:read',
44
+ 'tenant:app:read',
45
+ 'tenant:idp:read',
46
+ 'tenant:webhook:read',
47
+ 'tenant:mfa:read',
48
+ 'tenant:security:read',
49
+ 'tenant:branding:read',
50
+ 'tenant:notification:read',
51
+ 'tenant:communication:read',
52
+ 'tenant:storage:read',
53
+ 'tenant:wallet:read',
54
+ 'tenant:point:read',
55
+ ],
56
+ };
57
+
58
+ const EMPTY_PERMS: string[] = [];
59
+
60
+ export function usePermission(): PermissionResult {
61
+ // zustand selector 订阅 store:permissions 数组取 store 原引用(或模块级常量
62
+ // EMPTY_PERMS),role 为 string 原语 —— 两者均天然引用稳定,值相等即不触发
63
+ // 重渲染(Object.is 比较),根治 React #185 (Maximum update depth exceeded)。
64
+ const permissions = useAuthStore((s) => s.permissions || EMPTY_PERMS);
65
+ const perms = permissions;
66
+
67
+ const role = useCurrentRole();
68
+ const isAdminRole = role === 'super_admin' || role === 'admin';
69
+
70
+ // Build effective permission set: backend resource:action → frontend scope:resource:action
71
+ const effectivePerms = new Set<string>();
72
+ for (let i = 0; i < perms.length; i++) {
73
+ const p = perms[i];
74
+ if (p) {
75
+ effectivePerms.add(p);
76
+ const mapped = PERMISSION_MAP[p];
77
+ if (mapped) {
78
+ for (let j = 0; j < mapped.length; j++) {
79
+ effectivePerms.add(mapped[j]);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ const allPerms = Array.from(effectivePerms);
85
+
86
+ const can = (permission: string): boolean => {
87
+ if (effectivePerms.has(permission)) return true;
88
+ if (isAdminRole && permission.startsWith('tenant:')) return true;
89
+ if (isAdminRole && permission.startsWith('platform:')) return true;
90
+ return false;
91
+ };
92
+
93
+ const canAny = (...permissionList: string[]): boolean =>
94
+ permissionList.some((p) => effectivePerms.has(p));
95
+
96
+ const canAll = (...permissionList: string[]): boolean =>
97
+ permissionList.every((p) => effectivePerms.has(p));
98
+
99
+ const isSuperAdmin = allPerms.some((p) => p.startsWith('platform:'));
100
+
101
+ const isTenantAdmin = allPerms.some(
102
+ (p) => p.startsWith('tenant:') && (p.endsWith(':manage') || p.endsWith(':write')),
103
+ );
104
+
105
+ const isAuditor =
106
+ allPerms.some((p) => p.startsWith('tenant:audit:')) &&
107
+ !allPerms.some((p) => p.includes(':write') || p.includes(':delete'));
108
+
109
+ const isDeveloper =
110
+ allPerms.some((p) => p.startsWith('tenant:app:')) &&
111
+ !allPerms.some((p) => p.startsWith('tenant:user:'));
112
+
113
+ const isEndUser = allPerms.every((p) => p.startsWith('self:'));
114
+
115
+ return { can, canAny, canAll, isSuperAdmin, isTenantAdmin, isAuditor, isDeveloper, isEndUser };
116
+ }