@autional/shared 0.0.0-stage → 0.1.0-rc.32
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 +49 -3
- package/src/api/__tests__/contract.test.ts +172 -0
- package/src/api/__tests__/entry-plane.test.ts +119 -0
- package/src/api/__tests__/namespace.test.ts +27 -0
- package/src/api/__tests__/preflight-refresh.test.ts +89 -0
- package/src/api/bff-client.ts +94 -0
- package/src/api/client.ts +191 -0
- package/src/api/entry-plane.ts +61 -0
- package/src/api/namespace.ts +14 -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__/auth-trace.test.ts +321 -0
- package/src/auth/__tests__/build-login-url.test.ts +259 -0
- package/src/auth/__tests__/get-current-role.test.ts +61 -0
- package/src/auth/__tests__/jwt-payload.test.ts +49 -0
- package/src/auth/__tests__/logout-redirect.test.ts +44 -0
- package/src/auth/__tests__/logout-revoke.test.ts +219 -0
- package/src/auth/__tests__/oauth-callback-error-codes.test.ts +69 -0
- package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
- package/src/auth/__tests__/oauth-initiate-stay.test.ts +118 -0
- package/src/auth/__tests__/oauth-login-user-resolution.test.ts +185 -0
- package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
- package/src/auth/__tests__/session-client-id.test.ts +147 -0
- package/src/auth/__tests__/session-terminal-redirect.test.ts +138 -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/auth-trace.ts +511 -0
- package/src/auth/build-tenant-urls.ts +48 -0
- package/src/auth/jwt-payload.ts +21 -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 +388 -0
- package/src/auth/pkce.ts +141 -0
- package/src/auth/portal-config.ts +68 -0
- package/src/auth/roles.ts +120 -0
- package/src/auth/service.ts +433 -0
- package/src/auth/slug-from-url.ts +92 -0
- package/src/auth/store.ts +227 -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/__tests__/brand-vars.test.ts +126 -0
- package/src/branding/brand-color.ts +193 -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 +101 -0
- package/src/components/AuditStatsOnly.tsx +65 -0
- package/src/components/OAuthCallbackPage.tsx +83 -0
- package/src/components/PortalGuard.tsx +105 -0
- package/src/components/RequireAuth.tsx +220 -0
- package/src/components/TenantIndexGuard.tsx +131 -0
- package/src/components/TenantRootRedirect.tsx +49 -0
- package/src/components/__tests__/RequireAuth.adversarial.test.tsx +165 -0
- package/src/components/__tests__/RequireAuth.stay.test.tsx +131 -0
- package/src/components/__tests__/RequireAuth.test.tsx +296 -0
- package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
- package/src/components/__tests__/TenantRootRedirect.test.tsx +180 -0
- package/src/config/__tests__/portal-resolver.test.ts +302 -0
- package/src/config/__tests__/valid-redirect.test.ts +47 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +426 -0
- package/src/config/resolve-basename.ts +57 -0
- package/src/constants/api-paths.ts +450 -0
- package/src/generated/api.ts +13012 -0
- package/src/generated/queries.ts +12984 -0
- package/src/generated/types.ts +13982 -0
- package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
- package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
- package/src/hooks/__tests__/useCurrentTenantIdOr.test.tsx +38 -0
- package/src/hooks/__tests__/useLogout.test.tsx +61 -0
- package/src/hooks/__tests__/usePermission.map.test.tsx +156 -0
- package/src/hooks/__tests__/usePortalCatalog.test.tsx +301 -0
- package/src/hooks/__tests__/useSodMode.test.tsx +205 -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/useCurrentTenantIdOr.ts +29 -0
- package/src/hooks/useLogout.ts +42 -0
- package/src/hooks/usePermission.ts +128 -0
- package/src/hooks/usePortalCatalog.ts +199 -0
- package/src/hooks/useSodMode.ts +81 -0
- package/src/index.ts +247 -0
- package/src/lib/__tests__/push.test.ts +30 -0
- package/src/lib/push.ts +44 -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 +190 -0
- package/src/utils/__tests__/case.test.ts +94 -0
- package/src/utils/__tests__/error.test.ts +72 -0
- package/src/utils/__tests__/mock-test.ts +15 -0
- package/src/utils/__tests__/page.test.ts +54 -0
- package/src/utils/__tests__/query-state.test.ts +120 -0
- package/src/utils/browser.ts +15 -0
- package/src/utils/case.ts +59 -0
- package/src/utils/error.ts +35 -0
- package/src/utils/format.ts +63 -0
- package/src/utils/page.ts +57 -0
- package/src/utils/password-transmission.test.ts +145 -0
- package/src/utils/password-transmission.ts +253 -0
- package/src/utils/query-state.ts +85 -0
- package/src/utils/response.ts +71 -0
- package/src/utils/vite.ts +4 -0
- package/src/vite-env.d.ts +19 -0
- package/README.md +0 -3
|
@@ -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,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// 当前租户 ID 的**带显式回落**版本。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它(而不是让各门户各写一份):admin 与 platform 各有一份 hooks/use-tenant.ts,
|
|
6
|
+
// 同名同签名、语义却不同 —— admin 的 useTenantIdOr 会回落 tenants[0],platform 的直接返回入参。
|
|
7
|
+
// 那个差别不是风格:admin 的注释记着 BUG-013(硬编码 'default-tenant' 导致 data-classification 等页 404),
|
|
8
|
+
// 而 tenants[0] 是「已登录但没选租户」时唯一正确的答案。同一个函数名两种语义,迟早会有人在错误的那份上写新功能。
|
|
9
|
+
//
|
|
10
|
+
// 与 useCurrentTenantId() 的分工:那个是**只读、可空、诚实**的窄 hook(无租户就是 null);
|
|
11
|
+
// 这个在调用方确实需要一个字符串时用,并且**把回落值写在调用点上** —— 让「没有租户时会发生什么」看得见。
|
|
12
|
+
|
|
13
|
+
import { useCurrentTenantId } from './useAuth';
|
|
14
|
+
import { useTenants } from './useAuth';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 当前租户 ID,缺失时回落:已登录但未选租户 → 取 tenants[0].id;连租户列表都空 → 用调用方给的 fallback。
|
|
18
|
+
*
|
|
19
|
+
* 用法:
|
|
20
|
+
* const tenantId = useCurrentTenantIdOr(''); // 明确接受「空字符串」这一种无租户表示
|
|
21
|
+
* const tenantId = useCurrentTenantIdOr('default'); // 或给一个明确的兜底
|
|
22
|
+
*/
|
|
23
|
+
export function useCurrentTenantIdOr(fallback: string): string {
|
|
24
|
+
const currentTenantId = useCurrentTenantId();
|
|
25
|
+
const tenants = useTenants();
|
|
26
|
+
if (currentTenantId) return currentTenantId;
|
|
27
|
+
if (tenants && tenants.length > 0 && tenants[0].id) return tenants[0].id;
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一的登出 Hook
|
|
3
|
+
* 所有 portal 必须使用此 hook 处理登出,确保清理逻辑一致
|
|
4
|
+
* 委托给 AuthService.logout() 统一实现
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback } from 'react';
|
|
8
|
+
import { buildLogoutUrl } from '../auth/roles';
|
|
9
|
+
import { traceRedirect } from '../auth/auth-trace';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 统一的登出 Hook
|
|
13
|
+
* 委托 AuthService.logout() 执行清理(清除内存状态、localStorage、cookie、token 吊销),
|
|
14
|
+
* 然后跳转到登录页。
|
|
15
|
+
*
|
|
16
|
+
* 落点口径(AUTH-40):回程可用 `returnUrl` 显式覆盖——供当前 URL 无租户段、
|
|
17
|
+
* 调用方另持租户上下文的场景(auth 域裸 `/logout` 用登录期锚定标记补回);
|
|
18
|
+
* 缺省用捕获的当前 URL。EntryRouter 据回程解析租户 → `/<slug>/login`,否则 brand。
|
|
19
|
+
*/
|
|
20
|
+
export function useLogout(opts?: { returnUrl?: string }) {
|
|
21
|
+
const returnUrl = opts?.returnUrl;
|
|
22
|
+
return useCallback(() => {
|
|
23
|
+
const performLogout = async () => {
|
|
24
|
+
// 在 clearAuth/跳转前捕获当前 URL,避免时序竞争:
|
|
25
|
+
// AuthService.logout() 的 clearAuth 会触发 store 变化,
|
|
26
|
+
// 页面可能因此先导航到 '/',导致后续读到的 window.location.href 丢失租户 slug
|
|
27
|
+
const currentUrl =
|
|
28
|
+
returnUrl || (typeof window !== 'undefined' ? window.location.href : '');
|
|
29
|
+
|
|
30
|
+
// AuthService.logout() 统一处理:clearAuth + LS 清理 + cookie 清理 + token 吊销
|
|
31
|
+
const { AuthService } = await import('../auth/service');
|
|
32
|
+
await AuthService.logout();
|
|
33
|
+
|
|
34
|
+
// buildLogoutUrl = auth 域裸根 + `logout=1` 登出意图标记:入口路由据此先
|
|
35
|
+
// 终结会话再定落点(能解析出租户 → 裸 /<slug>/login;否则 brand 裸根),
|
|
36
|
+
// 而不是把带会话的回程当普通深链直送登录页(会静默重登)。
|
|
37
|
+
// funnel:用户主动行为,静默直跳(assign 保持原 href 历史语义);仅记 trace。
|
|
38
|
+
traceRedirect(buildLogoutUrl(currentUrl), { reason: 'logout', assign: true });
|
|
39
|
+
};
|
|
40
|
+
performLogout();
|
|
41
|
+
}, [returnUrl]);
|
|
42
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
// B1 裁撤(TASK-AB1-12):去掉 'tenant:user:read' —— member/guest 仅持 user:read 不再见 /users;
|
|
25
|
+
// user_manager 经 'user:create' 仍映射到 'tenant:user:read'(不回归)。
|
|
26
|
+
'user:read': ['tenant:dashboard:read', 'self:profile:read'],
|
|
27
|
+
'user:update': ['tenant:user:read'],
|
|
28
|
+
'user:delete': ['tenant:user:read'],
|
|
29
|
+
'role:create': ['tenant:role:read'],
|
|
30
|
+
// B2 裁撤(TASK-AB1-12):role:read 不再映射 'tenant:role:read' —— security_admin
|
|
31
|
+
// (11 码集仅 role:read)不再见 /roles 与 /role-activations;无其他映射源可授予导航键。
|
|
32
|
+
'role:read': [],
|
|
33
|
+
'role:update': ['tenant:role:read'],
|
|
34
|
+
'role:delete': ['tenant:role:read'],
|
|
35
|
+
'audit:read': ['tenant:audit:read', 'tenant:compliance:read'],
|
|
36
|
+
// D4 补源(TASK-AB1-12):NHI 父键 + 叶(/agents、/robots、/devices、/policies/nhi)可见性
|
|
37
|
+
'agent:read': ['tenant:nhi:read'],
|
|
38
|
+
'robot:read': ['tenant:nhi:read'],
|
|
39
|
+
'device:read': ['tenant:nhi:read'],
|
|
40
|
+
// D4 补源(TASK-AB1-12):/status 监控面(status:read)
|
|
41
|
+
'status:read': ['tenant:monitor:read'],
|
|
42
|
+
// D4 补源(TASK-AB1-12):/oauth-clients 开发者面(oauth:read)
|
|
43
|
+
'oauth:read': ['tenant:oauth:read'],
|
|
44
|
+
'profile:read': ['tenant:profile:read'],
|
|
45
|
+
'profile:write': ['tenant:profile:read'],
|
|
46
|
+
'profile:archive': ['tenant:profile:read'],
|
|
47
|
+
'profile:export': ['tenant:profile:read', 'tenant:compliance:read'],
|
|
48
|
+
'profile:delete': ['tenant:profile:read'],
|
|
49
|
+
'profile:manage': ['tenant:profile:read'],
|
|
50
|
+
'system:config': [
|
|
51
|
+
'tenant:permission:read',
|
|
52
|
+
'tenant:session:read',
|
|
53
|
+
'tenant:secret:manage',
|
|
54
|
+
'tenant:department:read',
|
|
55
|
+
'tenant:member:read',
|
|
56
|
+
'tenant:app:read',
|
|
57
|
+
'tenant:idp:read',
|
|
58
|
+
'tenant:webhook:read',
|
|
59
|
+
'tenant:mfa:read',
|
|
60
|
+
'tenant:security:read',
|
|
61
|
+
'tenant:branding:read',
|
|
62
|
+
'tenant:notification:read',
|
|
63
|
+
'tenant:communication:read',
|
|
64
|
+
'tenant:storage:read',
|
|
65
|
+
'tenant:wallet:read',
|
|
66
|
+
'tenant:point:read',
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const EMPTY_PERMS: string[] = [];
|
|
71
|
+
|
|
72
|
+
export function usePermission(): PermissionResult {
|
|
73
|
+
// zustand selector 订阅 store:permissions 数组取 store 原引用(或模块级常量
|
|
74
|
+
// EMPTY_PERMS),role 为 string 原语 —— 两者均天然引用稳定,值相等即不触发
|
|
75
|
+
// 重渲染(Object.is 比较),根治 React #185 (Maximum update depth exceeded)。
|
|
76
|
+
const permissions = useAuthStore((s) => s.permissions || EMPTY_PERMS);
|
|
77
|
+
const perms = permissions;
|
|
78
|
+
|
|
79
|
+
const role = useCurrentRole();
|
|
80
|
+
const isAdminRole = role === 'super_admin' || role === 'admin';
|
|
81
|
+
|
|
82
|
+
// Build effective permission set: backend resource:action → frontend scope:resource:action
|
|
83
|
+
const effectivePerms = new Set<string>();
|
|
84
|
+
for (let i = 0; i < perms.length; i++) {
|
|
85
|
+
const p = perms[i];
|
|
86
|
+
if (p) {
|
|
87
|
+
effectivePerms.add(p);
|
|
88
|
+
const mapped = PERMISSION_MAP[p];
|
|
89
|
+
if (mapped) {
|
|
90
|
+
for (let j = 0; j < mapped.length; j++) {
|
|
91
|
+
effectivePerms.add(mapped[j]);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const allPerms = Array.from(effectivePerms);
|
|
97
|
+
|
|
98
|
+
const can = (permission: string): boolean => {
|
|
99
|
+
if (effectivePerms.has(permission)) return true;
|
|
100
|
+
if (isAdminRole && permission.startsWith('tenant:')) return true;
|
|
101
|
+
if (isAdminRole && permission.startsWith('platform:')) return true;
|
|
102
|
+
return false;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const canAny = (...permissionList: string[]): boolean =>
|
|
106
|
+
permissionList.some((p) => effectivePerms.has(p));
|
|
107
|
+
|
|
108
|
+
const canAll = (...permissionList: string[]): boolean =>
|
|
109
|
+
permissionList.every((p) => effectivePerms.has(p));
|
|
110
|
+
|
|
111
|
+
const isSuperAdmin = allPerms.some((p) => p.startsWith('platform:'));
|
|
112
|
+
|
|
113
|
+
const isTenantAdmin = allPerms.some(
|
|
114
|
+
(p) => p.startsWith('tenant:') && (p.endsWith(':manage') || p.endsWith(':write')),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const isAuditor =
|
|
118
|
+
allPerms.some((p) => p.startsWith('tenant:audit:')) &&
|
|
119
|
+
!allPerms.some((p) => p.includes(':write') || p.includes(':delete'));
|
|
120
|
+
|
|
121
|
+
const isDeveloper =
|
|
122
|
+
allPerms.some((p) => p.startsWith('tenant:app:')) &&
|
|
123
|
+
!allPerms.some((p) => p.startsWith('tenant:user:'));
|
|
124
|
+
|
|
125
|
+
const isEndUser = allPerms.every((p) => p.startsWith('self:'));
|
|
126
|
+
|
|
127
|
+
return { can, canAny, canAll, isSuperAdmin, isTenantAdmin, isAuditor, isDeveloper, isEndUser };
|
|
128
|
+
}
|