@autional/shared 0.0.0-stage → 0.1.0-rc.33
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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autional 全局认证状态管理 (Zustand)
|
|
3
|
+
* 所有前端应用共用同一个 auth store
|
|
4
|
+
*
|
|
5
|
+
* 职责划分(AC-008):前端角色(tenants[].role 派生)仅用于 UX 呈现;
|
|
6
|
+
* 安全判断由后端 rbac-backed 授权强制,前端不做凭角色放行的敏感操作。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { create } from 'zustand';
|
|
10
|
+
import { persist } from 'zustand/middleware';
|
|
11
|
+
import type { User } from '../types';
|
|
12
|
+
import { traceRedirect } from './auth-trace';
|
|
13
|
+
import { decodeJwtPayload } from './jwt-payload';
|
|
14
|
+
|
|
15
|
+
interface AuthState {
|
|
16
|
+
// State
|
|
17
|
+
user: User | null;
|
|
18
|
+
accessToken: string | null;
|
|
19
|
+
refreshToken: string | null;
|
|
20
|
+
tenants: Array<{ id: string; name: string; role: string }>;
|
|
21
|
+
currentTenantId: string | null;
|
|
22
|
+
permissions: string[];
|
|
23
|
+
isAuthenticated: boolean;
|
|
24
|
+
|
|
25
|
+
// Actions
|
|
26
|
+
setAuth: (accessToken: string, refreshToken: string, user: User) => void;
|
|
27
|
+
setTokens: (accessToken: string, refreshToken: string) => void;
|
|
28
|
+
setUser: (user: User) => void;
|
|
29
|
+
setTenants: (tenants: Array<{ id: string; name: string; role: string }>) => void;
|
|
30
|
+
setCurrentTenant: (tenantId: string) => void;
|
|
31
|
+
switchTenant: (tenantId: string) => void;
|
|
32
|
+
setPermissions: (permissions: string[]) => void;
|
|
33
|
+
clearAuth: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const useAuthStore = create<AuthState>()(
|
|
37
|
+
persist(
|
|
38
|
+
(set) => ({
|
|
39
|
+
user: null,
|
|
40
|
+
accessToken: null,
|
|
41
|
+
refreshToken: null,
|
|
42
|
+
tenants: [],
|
|
43
|
+
currentTenantId: null,
|
|
44
|
+
permissions: [],
|
|
45
|
+
isAuthenticated: false,
|
|
46
|
+
|
|
47
|
+
setAuth: (accessToken, refreshToken, user) => {
|
|
48
|
+
if (!user) return;
|
|
49
|
+
const validToken = accessToken && accessToken !== 'undefined' && accessToken !== 'null';
|
|
50
|
+
set({
|
|
51
|
+
accessToken: validToken ? accessToken : null,
|
|
52
|
+
refreshToken: validToken ? refreshToken : null,
|
|
53
|
+
user,
|
|
54
|
+
isAuthenticated: true,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
setTokens: (accessToken, refreshToken) => set({ accessToken, refreshToken }),
|
|
59
|
+
|
|
60
|
+
setUser: (user) => set({ user }),
|
|
61
|
+
|
|
62
|
+
setTenants: (tenants) => set({ tenants }),
|
|
63
|
+
|
|
64
|
+
setCurrentTenant: (tenantId) => set({ currentTenantId: tenantId }),
|
|
65
|
+
|
|
66
|
+
switchTenant: (tenantId) => set({ currentTenantId: tenantId }),
|
|
67
|
+
|
|
68
|
+
setPermissions: (permissions) => set({ permissions }),
|
|
69
|
+
|
|
70
|
+
clearAuth: () =>
|
|
71
|
+
set({
|
|
72
|
+
user: null,
|
|
73
|
+
accessToken: null,
|
|
74
|
+
refreshToken: null,
|
|
75
|
+
tenants: [],
|
|
76
|
+
currentTenantId: null,
|
|
77
|
+
permissions: [],
|
|
78
|
+
isAuthenticated: false,
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
{
|
|
82
|
+
name: 'autional-auth-v1',
|
|
83
|
+
version: 1,
|
|
84
|
+
// Custom merge: validate persisted data integrity before merging into store.
|
|
85
|
+
// This prevents corrupted localStorage entries from silently breaking auth.
|
|
86
|
+
merge: (persisted, current) => {
|
|
87
|
+
if (!persisted || typeof persisted !== 'object') return current;
|
|
88
|
+
const state = { ...current, ...persisted };
|
|
89
|
+
|
|
90
|
+
// Fix: has accessToken but isAuthenticated is false
|
|
91
|
+
if (state.accessToken && !state.isAuthenticated) {
|
|
92
|
+
state.isAuthenticated = true;
|
|
93
|
+
}
|
|
94
|
+
// Fix: no accessToken but isAuthenticated is true (token was evicted)
|
|
95
|
+
if (!state.accessToken && state.isAuthenticated) {
|
|
96
|
+
state.isAuthenticated = false;
|
|
97
|
+
}
|
|
98
|
+
// Fix: has accessToken but user is missing — recover from JWT
|
|
99
|
+
if (state.accessToken && !state.user) {
|
|
100
|
+
const payload = decodeJwtPayload(state.accessToken);
|
|
101
|
+
if (payload) {
|
|
102
|
+
state.user = {
|
|
103
|
+
id: (payload.sub || payload.user_id) as string,
|
|
104
|
+
username: ((payload.custom as Record<string, unknown> | undefined)?.username ||
|
|
105
|
+
payload.username) as string,
|
|
106
|
+
email: (payload.email as string) || '',
|
|
107
|
+
status: 'active',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return state;
|
|
113
|
+
},
|
|
114
|
+
migrate: (persisted) => {
|
|
115
|
+
// v0 → v1: no structural changes, data is compatible
|
|
116
|
+
return persisted as any;
|
|
117
|
+
},
|
|
118
|
+
partialize: (state) => {
|
|
119
|
+
return {
|
|
120
|
+
accessToken:
|
|
121
|
+
state.accessToken && state.accessToken !== 'undefined' && state.accessToken !== 'null'
|
|
122
|
+
? state.accessToken
|
|
123
|
+
: null,
|
|
124
|
+
refreshToken:
|
|
125
|
+
state.refreshToken &&
|
|
126
|
+
state.refreshToken !== 'undefined' &&
|
|
127
|
+
state.refreshToken !== 'null'
|
|
128
|
+
? state.refreshToken
|
|
129
|
+
: null,
|
|
130
|
+
user: state.user,
|
|
131
|
+
tenants: state.tenants,
|
|
132
|
+
currentTenantId: state.currentTenantId,
|
|
133
|
+
permissions: state.permissions,
|
|
134
|
+
isAuthenticated: state.isAuthenticated,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
onRehydrateStorage: () => (state, error) => {
|
|
138
|
+
if (error || !state) return;
|
|
139
|
+
// merge already handles all fixups; this is a safety net
|
|
140
|
+
if (state.accessToken && !state.isAuthenticated) {
|
|
141
|
+
useAuthStore.setState({ isAuthenticated: true });
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 获取 access token
|
|
150
|
+
*/
|
|
151
|
+
export function getAccessToken(): string | null {
|
|
152
|
+
const state = useAuthStore.getState().accessToken;
|
|
153
|
+
if (state && state !== 'undefined' && state !== 'null') return state;
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 获取 refresh token
|
|
160
|
+
*/
|
|
161
|
+
export function getRefreshToken(): string | null {
|
|
162
|
+
const state = useAuthStore.getState().refreshToken;
|
|
163
|
+
if (state && state !== 'undefined' && state !== 'null') return state;
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 获取当前租户 ID
|
|
169
|
+
*/
|
|
170
|
+
export function getCurrentTenantId(): string | null {
|
|
171
|
+
return useAuthStore.getState().currentTenantId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 获取当前租户下的角色(userinfo-first,非响应式)
|
|
176
|
+
* 派生自 store.tenants + currentTenantId —— 不解析 token 载荷。
|
|
177
|
+
* 前端角色仅用于 UX 呈现;安全判断由后端 rbac-backed 授权强制(AC-008)。
|
|
178
|
+
*/
|
|
179
|
+
export function getCurrentRole(): string | null {
|
|
180
|
+
const { tenants, currentTenantId } = useAuthStore.getState();
|
|
181
|
+
if (!tenants || tenants.length === 0) return null;
|
|
182
|
+
if (!currentTenantId) return null;
|
|
183
|
+
return tenants.find((t) => t.id === currentTenantId)?.role ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 使用 token 登录(设置认证状态)
|
|
188
|
+
*/
|
|
189
|
+
export function loginWithTokens(
|
|
190
|
+
accessToken: string | null,
|
|
191
|
+
refreshToken: string | null,
|
|
192
|
+
user: User,
|
|
193
|
+
): void {
|
|
194
|
+
useAuthStore.getState().setAuth(accessToken || '', refreshToken || '', user);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 退出登录(清除认证状态 + localStorage/cookie)
|
|
199
|
+
* 委托给 AuthService.logout() 统一实现
|
|
200
|
+
* 使用动态 import 避免 circular dependency (service.ts → store.ts)
|
|
201
|
+
*
|
|
202
|
+
* redirectTo(U350):显式落点(会话过期→auth /error、注销完成→/<slug>/login?account_deleted=true 等
|
|
203
|
+
* 旗标页)——清理完成后整页前往。缺省仅清会话、不导航;注意与 useLogout 口径区分:该 hook 走
|
|
204
|
+
* buildLogoutUrl 登出意图流(logout=1 交入口路由定落点),不得用于需要保留业务 query 旗标的目标。
|
|
205
|
+
*/
|
|
206
|
+
export async function logout(redirectTo?: string): Promise<void> {
|
|
207
|
+
const { AuthService } = await import('./service');
|
|
208
|
+
await AuthService.logout();
|
|
209
|
+
if (redirectTo) {
|
|
210
|
+
traceRedirect(redirectTo, { reason: 'logout', assign: true });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isTokenExpired(token: string): boolean {
|
|
215
|
+
const payload = decodeJwtPayload(token);
|
|
216
|
+
const exp = payload?.exp as number | undefined;
|
|
217
|
+
if (!exp) return true;
|
|
218
|
+
return Date.now() >= exp * 1000;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function refreshAccessToken(): Promise<string | null> {
|
|
222
|
+
// 统一委托 AuthService.refreshToken()(含并发去重 + JWT/opaque 分流 + onUnauthorized 处理),
|
|
223
|
+
// 消除双实现:store 版不再持有重复的 refresh 逻辑(此前与 service 版不一致——无并发去重)。
|
|
224
|
+
// 动态 import 避免 store.ts ↔ service.ts 循环依赖。
|
|
225
|
+
const { AuthService } = await import('./service');
|
|
226
|
+
return AuthService.refreshToken();
|
|
227
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTenantRoute — 路由层中间件
|
|
3
|
+
*
|
|
4
|
+
* 从 URL slug 解析租户配置,与认证逻辑解耦。
|
|
5
|
+
* 代替 RequireAuth 直接使用 useOAuthClientIdFromUrl。
|
|
6
|
+
*
|
|
7
|
+
* 职责:
|
|
8
|
+
* - 从 URL 提取 tenant slug
|
|
9
|
+
* - 请求 OAuth 客户端配置 (slug → clientId)
|
|
10
|
+
* - 自动将 tenantId 写入 AuthService
|
|
11
|
+
* - 暴露 loading/notFound 状态给路由层
|
|
12
|
+
*
|
|
13
|
+
* @see docs/architecture/auth-next-issues-analysis.md
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import { useEffect } from 'react';
|
|
19
|
+
import { useOAuthClientIdFromUrl } from './oauth-client-from-slug';
|
|
20
|
+
import { AuthService } from './service';
|
|
21
|
+
import { getPortalUrl } from '../config';
|
|
22
|
+
|
|
23
|
+
export interface TenantRouteResult {
|
|
24
|
+
/** 租户 slug(从 URL 提取) */
|
|
25
|
+
slug: string | null;
|
|
26
|
+
/** 租户 ID(从 OAuth 配置获取) */
|
|
27
|
+
tenantId: string | null;
|
|
28
|
+
/** OAuth 客户端 ID */
|
|
29
|
+
oauthClientId: string | null;
|
|
30
|
+
/** 是否正在加载(API 请求中) */
|
|
31
|
+
loading: boolean;
|
|
32
|
+
/** 未找到(无 slug / 无 OAuth 配置 / slug 不存在) */
|
|
33
|
+
notFound: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* URL slug 确定性不存在(by-slug HTTP 404)。
|
|
36
|
+
* 门户口径(F-W6):本地渲染 404、不发弹跳 —— 否则与 auth 侧
|
|
37
|
+
* 「有会话 → 回跳 redirect」构成无限整页往返。
|
|
38
|
+
* 网络错误/5xx 不置本标记(fail-open,仍走原登录漏斗)。
|
|
39
|
+
*/
|
|
40
|
+
unknownSlug: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 路由层中间件:从 URL slug 解析租户配置。
|
|
45
|
+
*
|
|
46
|
+
* 用法:
|
|
47
|
+
* ```tsx
|
|
48
|
+
* function AppLayout() {
|
|
49
|
+
* const tenantRoute = useTenantRoute();
|
|
50
|
+
* if (tenantRoute.loading) return <Spin />;
|
|
51
|
+
* return <App tenantSlug={tenantRoute.slug} />;
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function useTenantRoute(): TenantRouteResult {
|
|
56
|
+
const slugResolution = useOAuthClientIdFromUrl();
|
|
57
|
+
|
|
58
|
+
// Auto-store tenantId in AuthService when resolved
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (slugResolution.status === 'resolved' && slugResolution.config.tenantId) {
|
|
61
|
+
AuthService.updateCurrentTenant(slugResolution.config.tenantId);
|
|
62
|
+
}
|
|
63
|
+
}, [slugResolution.status]);
|
|
64
|
+
|
|
65
|
+
// slug 在 useOAuthClientIdFromUrl 中通过 extractSlugFromPath 从 URL 提取,
|
|
66
|
+
// 通过 SlugResolution.resolved.slug 传递,无需从 API 响应中读取。
|
|
67
|
+
const slug = slugResolution.status === 'resolved' ? slugResolution.slug : null;
|
|
68
|
+
|
|
69
|
+
const unknownSlug = slugResolution.status === 'unknown-slug';
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
slug,
|
|
73
|
+
tenantId: slugResolution.status === 'resolved' ? slugResolution.config.tenantId || null : null,
|
|
74
|
+
oauthClientId: slugResolution.status === 'resolved' ? slugResolution.clientId : null,
|
|
75
|
+
loading: slugResolution.status === 'loading',
|
|
76
|
+
notFound: slugResolution.status === 'not-found' || unknownSlug,
|
|
77
|
+
unknownSlug,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 获取有效的 OAuth clientId:配置 > 环境变量 > slug 解析。
|
|
83
|
+
* 这是 redirect 决策的核心逻辑,提取为纯函数便于测试。
|
|
84
|
+
*/
|
|
85
|
+
export function resolveEffectiveClientId(
|
|
86
|
+
tenantRoute: TenantRouteResult,
|
|
87
|
+
envClientId?: string,
|
|
88
|
+
): string | null {
|
|
89
|
+
// slug 解析出的 clientId 优先(多租户场景)
|
|
90
|
+
if (tenantRoute.oauthClientId) return tenantRoute.oauthClientId;
|
|
91
|
+
// 环境变量其次(单租户/开发场景)
|
|
92
|
+
if (envClientId) return envClientId;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 判断 OAuth 是否在同域(slug-resolved 或同域 auth-pages)。
|
|
98
|
+
* SSR 安全:在服务端环境下始终返回 false。
|
|
99
|
+
*/
|
|
100
|
+
export function isSameDomainOAuth(tenantRoute: TenantRouteResult): boolean {
|
|
101
|
+
if (typeof window === 'undefined') return false; // SSR guard
|
|
102
|
+
if (tenantRoute.notFound) return false;
|
|
103
|
+
// slug 解析到的 client 一定是同域的(API 在同一 gateway 后)
|
|
104
|
+
if (tenantRoute.oauthClientId) return true;
|
|
105
|
+
// 检查 auth-pages URL 是否同域
|
|
106
|
+
try {
|
|
107
|
+
const authPagesUrl = getPortalUrl('auth');
|
|
108
|
+
if (!authPagesUrl) return false;
|
|
109
|
+
return new URL(authPagesUrl).origin === window.location.origin;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
const TenantSlugContext = createContext<string | undefined>(undefined);
|
|
4
|
+
|
|
5
|
+
export const TenantSlugProvider = TenantSlugContext.Provider;
|
|
6
|
+
|
|
7
|
+
export function useTenantSlug(): string | undefined {
|
|
8
|
+
return useContext(TenantSlugContext);
|
|
9
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useQuery } from '@tanstack/react-query';
|
|
5
|
+
import { useTenantSlugFromUrl } from '../auth/slug-from-url';
|
|
6
|
+
import { tenantPublicTenantsByTenants } from '../generated/api';
|
|
7
|
+
import { extractBranding } from './extract-branding';
|
|
8
|
+
import { readCachedBranding, writeCachedBranding } from './branding-cache';
|
|
9
|
+
import { useTenantBrandingStore } from './tenant-store';
|
|
10
|
+
import type { Branding } from './types';
|
|
11
|
+
|
|
12
|
+
const TTL = 6 * 60 * 60 * 1000; // 6h,与公开租户接口的缓存口径一致
|
|
13
|
+
|
|
14
|
+
export interface BrandingInitializerProps {
|
|
15
|
+
/**
|
|
16
|
+
* 覆盖 slug 来源。缺省按 URL 首段取(共享层口径);
|
|
17
|
+
* auth 站传入自己的判定——它要求至少两段,裸 /{slug} 视为无租户上下文。
|
|
18
|
+
*/
|
|
19
|
+
slug?: string | null;
|
|
20
|
+
/** 接口尚未返回时的兜底品牌(auth 站用 auth-config 里的 branding 段)。 */
|
|
21
|
+
fallback?: Branding | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 按 slug 拉公开品牌并写入 store;渲染 `null`,只做接线。 */
|
|
25
|
+
export function BrandingInitializer({ slug: slugProp, fallback }: BrandingInitializerProps) {
|
|
26
|
+
const urlSlug = useTenantSlugFromUrl();
|
|
27
|
+
const slug = slugProp !== undefined ? slugProp : urlSlug;
|
|
28
|
+
const setBranding = useTenantBrandingStore((s) => s.setBranding);
|
|
29
|
+
|
|
30
|
+
// 缓存先到:首屏立即有品牌(未命中则由 useBranding 回落 tokens 缺省)
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!slug) {
|
|
33
|
+
setBranding(null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const cached = readCachedBranding(slug);
|
|
37
|
+
if (cached) setBranding(cached);
|
|
38
|
+
}, [slug, setBranding]);
|
|
39
|
+
|
|
40
|
+
const { data } = useQuery<Branding | null>({
|
|
41
|
+
queryKey: ['tenant-branding', slug],
|
|
42
|
+
queryFn: async () => {
|
|
43
|
+
if (!slug) return null;
|
|
44
|
+
const res = await tenantPublicTenantsByTenants(slug);
|
|
45
|
+
const branding = extractBranding(res);
|
|
46
|
+
if (branding) writeCachedBranding(slug, branding);
|
|
47
|
+
return branding;
|
|
48
|
+
},
|
|
49
|
+
enabled: Boolean(slug),
|
|
50
|
+
staleTime: TTL,
|
|
51
|
+
gcTime: TTL * 2,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const resolved = data ?? fallback ?? null;
|
|
56
|
+
if (resolved) setBranding(resolved);
|
|
57
|
+
}, [data, fallback, setBranding]);
|
|
58
|
+
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
3
|
+
import { applyBrandColors } from '../useBranding';
|
|
4
|
+
import { hexToTriplet, deriveDarkColor, deriveTextColor } from '../brand-color';
|
|
5
|
+
|
|
6
|
+
// U394 回归锁:品牌运行时注入必须同时写 `-rgb` 通道三元组。
|
|
7
|
+
// 反证:删掉 useBranding 的 setColorVar 伴随行(只写 hex),本文件断言必须 FAIL——
|
|
8
|
+
// 仅 hex 的品牌变量会让 Tailwind alpha 类(text-brand/60 → rgb(var(--color-brand-base-rgb)/0.6))
|
|
9
|
+
// 在品牌租户下解析不到通道,整条 alpha 色静默丢失。
|
|
10
|
+
|
|
11
|
+
const ALL_VARS = [
|
|
12
|
+
'--color-brand-base',
|
|
13
|
+
'--color-brand-hover-base',
|
|
14
|
+
'--color-brand-dark',
|
|
15
|
+
'--color-brand-dark-hover',
|
|
16
|
+
'--color-brand-text-base',
|
|
17
|
+
'--color-on-brand-base',
|
|
18
|
+
'--color-on-brand',
|
|
19
|
+
'--color-on-brand-dark',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
document.documentElement.removeAttribute('style');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('hexToTriplet', () => {
|
|
27
|
+
it('6 位 hex → 空格分隔三元组', () => {
|
|
28
|
+
expect(hexToTriplet('#003153')).toBe('0 49 83');
|
|
29
|
+
expect(hexToTriplet('#ffffff')).toBe('255 255 255');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('3 位缩写与大写归一', () => {
|
|
33
|
+
expect(hexToTriplet('#123')).toBe('17 34 51');
|
|
34
|
+
expect(hexToTriplet('#FFBF00')).toBe('255 191 0');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('非法输入 → null,不抛错(N3 防御)', () => {
|
|
38
|
+
expect(hexToTriplet('red')).toBeNull();
|
|
39
|
+
expect(hexToTriplet('#xyz')).toBeNull();
|
|
40
|
+
expect(hexToTriplet('')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('applyBrandColors — -rgb 伴随写入', () => {
|
|
45
|
+
it('合法品牌色:7 个色变量各写 hex + 三元组', () => {
|
|
46
|
+
applyBrandColors('#003153');
|
|
47
|
+
const s = document.documentElement.style;
|
|
48
|
+
expect(s.getPropertyValue('--color-brand-base')).toBe('#003153');
|
|
49
|
+
expect(s.getPropertyValue('--color-brand-base-rgb')).toBe('0 49 83');
|
|
50
|
+
// AUTH-05:brand-base 白底 13.4:1 已达标 → 文本色原样;不达标色见 deriveTextColor 用例
|
|
51
|
+
expect(s.getPropertyValue('--color-brand-text-base')).toBe('#003153');
|
|
52
|
+
expect(s.getPropertyValue('--color-brand-text-base-rgb')).toBe('0 49 83');
|
|
53
|
+
expect(s.getPropertyValue('--color-brand-hover-base-rgb')).toBe('0 49 83');
|
|
54
|
+
expect(s.getPropertyValue('--color-brand-dark-rgb')).toBe(hexToTriplet(deriveDarkColor('#003153')));
|
|
55
|
+
expect(s.getPropertyValue('--color-brand-dark-hover-rgb')).not.toBe('');
|
|
56
|
+
expect(s.getPropertyValue('--color-on-brand-base-rgb')).toBe('255 255 255');
|
|
57
|
+
expect(s.getPropertyValue('--color-on-brand-dark-rgb')).not.toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('darkOverride 生效时 base 与 dark 三元组各自独立', () => {
|
|
61
|
+
applyBrandColors('#003153', '#87ceeb');
|
|
62
|
+
const s = document.documentElement.style;
|
|
63
|
+
expect(s.getPropertyValue('--color-brand-base-rgb')).toBe('0 49 83');
|
|
64
|
+
expect(s.getPropertyValue('--color-brand-dark-rgb')).toBe('135 206 235');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('清空(空串):所有基础变量与 -rgb 伴随一并移除', () => {
|
|
68
|
+
applyBrandColors('#003153');
|
|
69
|
+
applyBrandColors('');
|
|
70
|
+
const s = document.documentElement.style;
|
|
71
|
+
for (const v of ALL_VARS) {
|
|
72
|
+
expect(s.getPropertyValue(v), v).toBe('');
|
|
73
|
+
expect(s.getPropertyValue(`${v}-rgb`), `${v}-rgb`).toBe('');
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('非 hex 值:写原值但移除 -rgb(不残留上一租户通道)', () => {
|
|
78
|
+
applyBrandColors('#003153');
|
|
79
|
+
applyBrandColors('red');
|
|
80
|
+
const s = document.documentElement.style;
|
|
81
|
+
expect(s.getPropertyValue('--color-brand-base')).toBe('red');
|
|
82
|
+
expect(s.getPropertyValue('--color-brand-base-rgb')).toBe('');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/** WCAG 2.x 对比度(测试内独立复算,锁 AUTH-05 派生结果对白底达标) */
|
|
87
|
+
function contrastOnWhite(hex: string): number {
|
|
88
|
+
const d = hex.replace('#', '');
|
|
89
|
+
const [r, g, b] = [0, 2, 4].map((i) => parseInt(d.slice(i, i + 2), 16) / 255);
|
|
90
|
+
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
91
|
+
return 1.05 / (0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) + 0.05);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe('deriveTextColor — 浅底品牌文本色(AUTH-05)', () => {
|
|
95
|
+
it('#1890ff(白底 3.24:1 不达标)→ 压暗至 ≥4.5:1,且不等于原值', () => {
|
|
96
|
+
const out = deriveTextColor('#1890ff');
|
|
97
|
+
expect(out).not.toBe('#1890ff');
|
|
98
|
+
expect(contrastOnWhite(out)).toBeGreaterThanOrEqual(4.5);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('达标输入原样返回(#003153 白底 ≈13.4:1)', () => {
|
|
102
|
+
expect(deriveTextColor('#003153')).toBe('#003153');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('#ffffff → 必然压暗且达标', () => {
|
|
106
|
+
const out = deriveTextColor('#ffffff');
|
|
107
|
+
expect(out).not.toBe('#ffffff');
|
|
108
|
+
expect(contrastOnWhite(out)).toBeGreaterThanOrEqual(4.5);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('非法输入 → 返回原值不抛错(N3 防御)', () => {
|
|
112
|
+
expect(deriveTextColor('red')).toBe('red');
|
|
113
|
+
expect(deriveTextColor('#xyz')).toBe('#xyz');
|
|
114
|
+
expect(deriveTextColor('')).toBe('');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('applyBrandColors(#1890ff):填充不变、文本另派生(AUTH-05 核心断言)', () => {
|
|
118
|
+
applyBrandColors('#1890ff');
|
|
119
|
+
const s = document.documentElement.style;
|
|
120
|
+
expect(s.getPropertyValue('--color-brand-base')).toBe('#1890ff');
|
|
121
|
+
const textBase = s.getPropertyValue('--color-brand-text-base');
|
|
122
|
+
expect(textBase).not.toBe('');
|
|
123
|
+
expect(textBase).not.toBe('#1890ff');
|
|
124
|
+
expect(contrastOnWhite(textBase)).toBeGreaterThanOrEqual(4.5);
|
|
125
|
+
});
|
|
126
|
+
});
|