@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,193 @@
|
|
|
1
|
+
// brand-color.ts — OKLab (Ottosson) 颜色转换 + WCAG 2.x 对比度 + 暗色品牌推导
|
|
2
|
+
// 纯函数,零外部依赖。移植自 butler/requirements/design/brand-contrast.mjs(保留全部常数)
|
|
3
|
+
// 防御 (N3): 非法 hex 输入(非 #rgb/#rrggbb、空串、null/undefined)返回原值/安全默认,不抛错,
|
|
4
|
+
// 避免租户数据异常产生 invalid CSS 变量值。
|
|
5
|
+
|
|
6
|
+
type Rgb = [number, number, number];
|
|
7
|
+
|
|
8
|
+
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
9
|
+
|
|
10
|
+
/** 归一化为小写 #rrggbb(支持 #rgb 展开 + 大写 hex);非法输入返回 null */
|
|
11
|
+
function normalizeHex(hex: unknown): string | null {
|
|
12
|
+
if (typeof hex !== 'string') return null;
|
|
13
|
+
const h = hex.trim();
|
|
14
|
+
if (!HEX_RE.test(h)) return null;
|
|
15
|
+
let digits = h.slice(1).toLowerCase();
|
|
16
|
+
if (digits.length === 3)
|
|
17
|
+
digits = digits
|
|
18
|
+
.split('')
|
|
19
|
+
.map((c) => c + c)
|
|
20
|
+
.join('');
|
|
21
|
+
return `#${digits}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── WCAG 2.x 相对亮度 / 对比度(脚本 L4-20)──
|
|
25
|
+
function hexToRgb(hex: string): Rgb {
|
|
26
|
+
const norm = normalizeHex(hex);
|
|
27
|
+
if (!norm) return [0, 0, 0];
|
|
28
|
+
const d = norm.slice(1);
|
|
29
|
+
return [0, 2, 4].map((i) => parseInt(d.slice(i, i + 2), 16) / 255) as Rgb;
|
|
30
|
+
}
|
|
31
|
+
function lin(c: number): number {
|
|
32
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
33
|
+
}
|
|
34
|
+
function lum(rgb: Rgb): number {
|
|
35
|
+
const [r, g, b] = rgb.map(lin) as [number, number, number];
|
|
36
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
37
|
+
}
|
|
38
|
+
function contrast(a: Rgb, b: Rgb): number {
|
|
39
|
+
const l1 = lum(a);
|
|
40
|
+
const l2 = lum(b);
|
|
41
|
+
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
42
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── sRGB ↔ OKLab (Ottosson,脚本 L22-46,含 Math.cbrt 与输出钳位) ──
|
|
46
|
+
function srgbToOklab(rgb: Rgb): [number, number, number] {
|
|
47
|
+
const [r, g, b] = rgb.map(lin) as [number, number, number];
|
|
48
|
+
let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
49
|
+
let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
50
|
+
let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
51
|
+
l = Math.cbrt(l);
|
|
52
|
+
m = Math.cbrt(m);
|
|
53
|
+
s = Math.cbrt(s);
|
|
54
|
+
return [
|
|
55
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
56
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
57
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
function oklabToSrgb([L, a, b]: [number, number, number]): Rgb {
|
|
61
|
+
const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
|
|
62
|
+
const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
|
|
63
|
+
const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
|
|
64
|
+
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
65
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
66
|
+
const b2 = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
67
|
+
const conv = (c: number): number => {
|
|
68
|
+
c = Math.min(1, Math.max(0, c)); // 钳位(脚本 L42)
|
|
69
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
70
|
+
};
|
|
71
|
+
return [conv(r), conv(g), conv(b2)];
|
|
72
|
+
}
|
|
73
|
+
function rgbToHex(rgb: Rgb): string {
|
|
74
|
+
return (
|
|
75
|
+
'#' +
|
|
76
|
+
rgb
|
|
77
|
+
.map((c) =>
|
|
78
|
+
Math.round(c * 255)
|
|
79
|
+
.toString(16)
|
|
80
|
+
.padStart(2, '0'),
|
|
81
|
+
)
|
|
82
|
+
.join('')
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** hex → `r g b` 通道三元组(供 `--color-*-rgb` 伴随变量,Tailwind alpha 路径消费)。
|
|
87
|
+
* 防御 (N3): 非法输入返回 null(调用方回退,不抛错)。 */
|
|
88
|
+
export function hexToTriplet(hex: string): string | null {
|
|
89
|
+
const norm = normalizeHex(hex);
|
|
90
|
+
if (!norm) return null;
|
|
91
|
+
const d = norm.slice(1);
|
|
92
|
+
return [0, 2, 4].map((i) => parseInt(d.slice(i, i + 2), 16)).join(' ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** hex → OKLCH [L, C, H](防御: 非法输入返回 [0, 0, 0],不抛错) */
|
|
96
|
+
export function hexToOklch(hex: string): [number, number, number] {
|
|
97
|
+
if (normalizeHex(hex) === null) return [0, 0, 0];
|
|
98
|
+
const [L, a, b] = srgbToOklab(hexToRgb(hex));
|
|
99
|
+
return [L, Math.hypot(a, b), Math.atan2(b, a)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** OKLCH [L, C, H] → hex(防御: 非法输入返回 '#000000',不抛错) */
|
|
103
|
+
export function oklchToHex(oklch: [number, number, number]): string {
|
|
104
|
+
if (
|
|
105
|
+
!Array.isArray(oklch) ||
|
|
106
|
+
oklch.length < 3 ||
|
|
107
|
+
oklch.slice(0, 3).some((v) => typeof v !== 'number' || Number.isNaN(v))
|
|
108
|
+
) {
|
|
109
|
+
return '#000000';
|
|
110
|
+
}
|
|
111
|
+
const [L, C, H] = oklch;
|
|
112
|
+
return rgbToHex(oklabToSrgb([L, C * Math.cos(H), C * Math.sin(H)]));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 向白色混入比例 t: OKLCH[L+(1-L)·t, C·(1-t), H](保持色相,抬升 L、压降 C,脚本 L58-63) */
|
|
116
|
+
function mixWhite(hex: string, t: number): string {
|
|
117
|
+
const [L, C, H] = hexToOklch(hex);
|
|
118
|
+
return oklchToHex([L + (1 - L) * t, C * (1 - t), H]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 向黑色混入比例 t: OKLCH[L·(1-t), C·(1-t), H](与 mixWhite 镜像:压低 L 与 C,保持色相)。
|
|
122
|
+
* 供 deriveTextColor——浅底上的品牌文本色需压暗而非抬亮。 */
|
|
123
|
+
function mixBlack(hex: string, t: number): string {
|
|
124
|
+
const [L, C, H] = hexToOklch(hex);
|
|
125
|
+
return oklchToHex([L * (1 - t), C * (1 - t), H]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 内部函数(不导出): 二分 24 迭代求最小 t∈[0,cap],使 mix(hex,t) 对 refBg 对比度 ≥ target。
|
|
129
|
+
* mix 默认 mixWhite(暗底抬亮);deriveTextColor 传 mixBlack(浅底压暗)。
|
|
130
|
+
* 供 deriveDarkColor / deriveDarkHover / deriveTextColor 复用,避免二次二分(ADR-003)。 */
|
|
131
|
+
function deriveT(
|
|
132
|
+
hex: string,
|
|
133
|
+
refBg: string,
|
|
134
|
+
target: number,
|
|
135
|
+
cap: number,
|
|
136
|
+
mix: (hex: string, t: number) => string = mixWhite,
|
|
137
|
+
): number {
|
|
138
|
+
let lo = 0;
|
|
139
|
+
let hi = cap;
|
|
140
|
+
const bgRgb = hexToRgb(refBg);
|
|
141
|
+
for (let i = 0; i < 24; i++) {
|
|
142
|
+
const mid = (lo + hi) / 2;
|
|
143
|
+
if (contrast(hexToRgb(mix(hex, mid)), bgRgb) >= target) hi = mid;
|
|
144
|
+
else lo = mid;
|
|
145
|
+
}
|
|
146
|
+
return (lo + hi) / 2;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 推导暗色品牌变体 = mixWhite(hex, deriveT(...))。
|
|
150
|
+
* D3 锁定默认参数: refBg='#0a2940', target=4.55, cap=0.85。
|
|
151
|
+
* 防御 (N3): 非法 hex → 返回原值不抛错(不产生 invalid CSS 变量值)。 */
|
|
152
|
+
export function deriveDarkColor(hex: string, refBg = '#0a2940', target = 4.55, cap = 0.85): string {
|
|
153
|
+
if (normalizeHex(hex) === null) return hex;
|
|
154
|
+
const bg = normalizeHex(refBg) ?? '#0a2940';
|
|
155
|
+
const tgt = typeof target === 'number' && !Number.isNaN(target) ? target : 4.55;
|
|
156
|
+
const c = typeof cap === 'number' && !Number.isNaN(cap) ? Math.max(0, Math.min(cap, 1)) : 0.85;
|
|
157
|
+
return mixWhite(hex, deriveT(hex, bg, tgt, c));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 推导浅底(默认白)上的品牌文本色:已达标返回原值,否则沿 mixBlack 压暗至对比度 ≥ target。
|
|
161
|
+
* 与 deriveDarkColor 方向相反(后者往白里混供暗底)——AUTH-05:品牌填充色直接作文本
|
|
162
|
+
* (text-[var(--color-brand)])对白底对比度随租户色浮动(#003153→13.4:1,#1890ff→3.24:1),
|
|
163
|
+
* 本函数给出「填充不变、文本另派生」的安全值,运行期写 --color-brand-text-base。
|
|
164
|
+
* 默认参数: refBg='#ffffff', target=4.55, cap=0.85。
|
|
165
|
+
* 防御 (N3): 非法 hex → 返回原值不抛错(不产生 invalid CSS 变量值)。 */
|
|
166
|
+
export function deriveTextColor(hex: string, refBg = '#ffffff', target = 4.55, cap = 0.85): string {
|
|
167
|
+
if (normalizeHex(hex) === null) return hex;
|
|
168
|
+
const bg = normalizeHex(refBg) ?? '#ffffff';
|
|
169
|
+
const tgt = typeof target === 'number' && !Number.isNaN(target) ? target : 4.55;
|
|
170
|
+
const c = typeof cap === 'number' && !Number.isNaN(cap) ? Math.max(0, Math.min(cap, 1)) : 0.85;
|
|
171
|
+
if (contrast(hexToRgb(hex), hexToRgb(bg)) >= tgt) return hex;
|
|
172
|
+
return mixBlack(hex, deriveT(hex, bg, tgt, c, mixBlack));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** on-brand 文本色: 选 '#ffffff' 与 '#0a0f1a' 中对比度更高者(保证 ≥4.5)。
|
|
176
|
+
* 防御: 非法 fill → '#0a0f1a'(安全默认,不抛错)。 */
|
|
177
|
+
export function pickOnColor(fill: string): '#ffffff' | '#0a0f1a' {
|
|
178
|
+
if (normalizeHex(fill) === null) return '#0a0f1a';
|
|
179
|
+
const white: Rgb = [1, 1, 1];
|
|
180
|
+
const dark = hexToRgb('#0a0f1a');
|
|
181
|
+
return contrast(white, hexToRgb(fill)) >= contrast(dark, hexToRgb(fill)) ? '#ffffff' : '#0a0f1a';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** hover 暗色变体 = mixWhite(color, min(t+0.08, 0.92)),t 复用 deriveT(ADR-003/D4)。
|
|
185
|
+
* 防御 (N3): 非法 hex → 返回原值不抛错。 */
|
|
186
|
+
export function deriveDarkHover(hex: string, refBg = '#0a2940', target = 4.55, cap = 0.85): string {
|
|
187
|
+
if (normalizeHex(hex) === null) return hex;
|
|
188
|
+
const bg = normalizeHex(refBg) ?? '#0a2940';
|
|
189
|
+
const tgt = typeof target === 'number' && !Number.isNaN(target) ? target : 4.55;
|
|
190
|
+
const c = typeof cap === 'number' && !Number.isNaN(cap) ? Math.max(0, Math.min(cap, 1)) : 0.85;
|
|
191
|
+
const t = deriveT(hex, bg, tgt, c);
|
|
192
|
+
return mixWhite(hex, Math.min(t + 0.08, 0.92));
|
|
193
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Branding } from './types';
|
|
2
|
+
import { extractBranding } from './extract-branding';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 缓存键。三套前缀都要认——顺序不能改,越靠前越早、越权威。
|
|
6
|
+
*
|
|
7
|
+
* page-init:tenant-branding: 页面内联脚本在 React 之前写入 → 首帧就有缓存色可上
|
|
8
|
+
* tenant-branding: 运行时写入(writeCachedBranding)
|
|
9
|
+
* page-init:auth-config: / auth-config: auth-config 里也带 branding 段,作为兜底
|
|
10
|
+
*
|
|
11
|
+
* 只认第二套的代价是实测过的:首帧读不到缓存,先闪一次品牌默认色再跳到租户色。
|
|
12
|
+
* auth 站的同类实现一直读的是 page-init 键,这里把能力并到共享版,避免两套各读各的。
|
|
13
|
+
*/
|
|
14
|
+
export const BRANDING_CACHE_PREFIX = 'tenant-branding:';
|
|
15
|
+
export const PAGE_INIT_BRANDING_PREFIX = 'page-init:tenant-branding:';
|
|
16
|
+
export const AUTH_CONFIG_PREFIX = 'auth-config:';
|
|
17
|
+
export const PAGE_INIT_AUTH_CONFIG_PREFIX = 'page-init:auth-config:';
|
|
18
|
+
|
|
19
|
+
function get(key: string): string | null {
|
|
20
|
+
try {
|
|
21
|
+
return localStorage.getItem(key);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 兼容 `{data, _ts}` 包装与裸对象两种历史写入形态。 */
|
|
28
|
+
function unwrap(raw: string | null): Branding | null {
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(raw) as { data?: Branding } | Branding;
|
|
32
|
+
return ((parsed as { data?: Branding })?.data ?? parsed) as Branding;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** auth-config 缓存里也带 branding 段——作为最后兜底。 */
|
|
39
|
+
function fromAuthConfig(slug: string): Branding | null {
|
|
40
|
+
for (const prefix of [PAGE_INIT_AUTH_CONFIG_PREFIX, AUTH_CONFIG_PREFIX]) {
|
|
41
|
+
const raw = get(prefix + slug);
|
|
42
|
+
if (!raw) continue;
|
|
43
|
+
try {
|
|
44
|
+
const cfg = JSON.parse(raw) as { data?: { branding?: unknown } };
|
|
45
|
+
const branding = extractBranding(cfg?.data?.branding);
|
|
46
|
+
if (branding) return branding;
|
|
47
|
+
} catch {
|
|
48
|
+
/* 坏数据视为没有 */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readCachedBranding(slug: string): Branding | null {
|
|
55
|
+
return (
|
|
56
|
+
unwrap(get(PAGE_INIT_BRANDING_PREFIX + slug)) ??
|
|
57
|
+
unwrap(get(BRANDING_CACHE_PREFIX + slug)) ??
|
|
58
|
+
fromAuthConfig(slug)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function writeCachedBranding(slug: string, branding: Branding): void {
|
|
63
|
+
try {
|
|
64
|
+
localStorage.setItem(
|
|
65
|
+
BRANDING_CACHE_PREFIX + slug,
|
|
66
|
+
JSON.stringify({ data: branding, _ts: Date.now() }),
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
/* storage full or unavailable */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Branding } from './types';
|
|
2
|
+
|
|
3
|
+
/** 从 public tenant 接口响应里取 branding 段(兼容 snake_case / camelCase,且兼容已解包的对象)。 */
|
|
4
|
+
export function extractBranding(raw: unknown): Branding | null {
|
|
5
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
6
|
+
const record = raw as Record<string, unknown>;
|
|
7
|
+
const source = (record.branding ?? record) as Record<string, string | undefined>;
|
|
8
|
+
if (!source || typeof source !== 'object') return null;
|
|
9
|
+
|
|
10
|
+
const logoUrl = source.logo_url || source.logoUrl || '';
|
|
11
|
+
const primaryColor = source.primary_color || source.primaryColor || '';
|
|
12
|
+
if (!logoUrl && !primaryColor) return null;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
primaryColor,
|
|
16
|
+
primaryColorDark: source.primary_color_dark || source.primaryColorDark || undefined,
|
|
17
|
+
logoUrl,
|
|
18
|
+
faviconUrl: source.favicon_url || source.faviconUrl || '',
|
|
19
|
+
customCss: source.custom_css || source.customCss || '',
|
|
20
|
+
secondaryColor: source.secondary_color || source.secondaryColor || undefined,
|
|
21
|
+
companyName: source.company_name || source.companyName || undefined,
|
|
22
|
+
loginPageTitle: source.login_page_title || source.loginPageTitle || undefined,
|
|
23
|
+
loginPageDescription: source.login_page_description || source.loginPageDescription || undefined,
|
|
24
|
+
privacyPolicyUrl: source.privacy_policy_url || source.privacyPolicyUrl || undefined,
|
|
25
|
+
termsOfServiceUrl: source.terms_of_service_url || source.termsOfServiceUrl || undefined,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { useBranding, applyBrandColors } from './useBranding';
|
|
2
|
+
export { BrandingInitializer } from './BrandingInitializer';
|
|
3
|
+
export type { BrandingInitializerProps } from './BrandingInitializer';
|
|
4
|
+
export { useTenantBrandingStore } from './tenant-store';
|
|
5
|
+
export { extractBranding } from './extract-branding';
|
|
6
|
+
export {
|
|
7
|
+
readCachedBranding,
|
|
8
|
+
writeCachedBranding,
|
|
9
|
+
BRANDING_CACHE_PREFIX,
|
|
10
|
+
PAGE_INIT_BRANDING_PREFIX,
|
|
11
|
+
AUTH_CONFIG_PREFIX,
|
|
12
|
+
PAGE_INIT_AUTH_CONFIG_PREFIX,
|
|
13
|
+
} from './branding-cache';
|
|
14
|
+
export { deriveDarkColor, deriveDarkHover, pickOnColor, hexToTriplet, hexToOklch, oklchToHex } from './brand-color';
|
|
15
|
+
export type { Branding } from './types';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import type { Branding } from './types';
|
|
3
|
+
|
|
4
|
+
interface TenantBrandingState {
|
|
5
|
+
branding: Branding | null;
|
|
6
|
+
setBranding: (branding: Branding | null) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 品牌 store —— `BrandingInitializer` 写入,`useBranding` 消费并落到 CSS 变量。 */
|
|
10
|
+
export const useTenantBrandingStore = create<TenantBrandingState>((set) => ({
|
|
11
|
+
branding: null,
|
|
12
|
+
setBranding: (branding) => set({ branding }),
|
|
13
|
+
}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 租户公开品牌契约(与 tenant-service `GET /tenant/public/tenants/{slug}` 的 branding 段一致)。 */
|
|
2
|
+
export interface Branding {
|
|
3
|
+
primaryColor: string;
|
|
4
|
+
primaryColorDark?: string;
|
|
5
|
+
logoUrl: string;
|
|
6
|
+
faviconUrl: string;
|
|
7
|
+
customCss: string;
|
|
8
|
+
secondaryColor?: string;
|
|
9
|
+
companyName?: string;
|
|
10
|
+
loginPageTitle?: string;
|
|
11
|
+
loginPageDescription?: string;
|
|
12
|
+
privacyPolicyUrl?: string;
|
|
13
|
+
termsOfServiceUrl?: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
4
|
+
import { useTenantSlugFromUrl } from '../auth/slug-from-url';
|
|
5
|
+
import { deriveDarkColor, deriveDarkHover, deriveTextColor, hexToTriplet, pickOnColor } from './brand-color';
|
|
6
|
+
import { readCachedBranding } from './branding-cache';
|
|
7
|
+
import { useTenantBrandingStore } from './tenant-store';
|
|
8
|
+
|
|
9
|
+
/** 品牌注入的 8 个 CSS 变量(与 auth 站同口径,tokens.css 提供缺省值)。 */
|
|
10
|
+
const BRAND_VARS = [
|
|
11
|
+
'--color-brand-base',
|
|
12
|
+
'--color-brand-hover-base',
|
|
13
|
+
'--color-brand-dark',
|
|
14
|
+
'--color-brand-dark-hover',
|
|
15
|
+
'--color-brand-text-base',
|
|
16
|
+
'--color-on-brand-base',
|
|
17
|
+
'--color-on-brand',
|
|
18
|
+
'--color-on-brand-dark',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** 写一个色变量及其 `-rgb` 通道三元组——Tailwind alpha 修饰符(text-brand/60)走
|
|
22
|
+
* `rgb(var(--color-X-rgb) / a)`,只写 hex 会让品牌租户的透明度色整条丢失。
|
|
23
|
+
* 非法非 hex 值:写原值但不写三元组(回落 tokens.css 缺省),避免残留上个租户的通道。 */
|
|
24
|
+
function setColorVar(root: HTMLElement, name: string, value: string): void {
|
|
25
|
+
root.style.setProperty(name, value);
|
|
26
|
+
const triplet = hexToTriplet(value);
|
|
27
|
+
if (triplet) root.style.setProperty(`${name}-rgb`, triplet);
|
|
28
|
+
else root.style.removeProperty(`${name}-rgb`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyBrandColors(color: string, darkOverride?: string): void {
|
|
32
|
+
const root = document.documentElement;
|
|
33
|
+
if (!color) {
|
|
34
|
+
BRAND_VARS.forEach((v) => {
|
|
35
|
+
root.style.removeProperty(v);
|
|
36
|
+
root.style.removeProperty(`${v}-rgb`);
|
|
37
|
+
});
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const dark = darkOverride || deriveDarkColor(color);
|
|
41
|
+
setColorVar(root, '--color-brand-base', color);
|
|
42
|
+
setColorVar(root, '--color-brand-hover-base', color);
|
|
43
|
+
setColorVar(root, '--color-brand-dark', dark);
|
|
44
|
+
setColorVar(root, '--color-brand-dark-hover', deriveDarkHover(color));
|
|
45
|
+
// 品牌色作文本的安全派生值(AUTH-05):text-brand-text 消费——浅底不达标即压暗
|
|
46
|
+
setColorVar(root, '--color-brand-text-base', deriveTextColor(color));
|
|
47
|
+
setColorVar(root, '--color-on-brand-base', pickOnColor(color));
|
|
48
|
+
setColorVar(root, '--color-on-brand-dark', pickOnColor(dark));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 把当前租户品牌落到 DOM(CSS 变量 / favicon / customCss)。
|
|
53
|
+
* 需与 `<BrandingInitializer/>` 同时挂载:前者负责写 store,这里负责呈现。
|
|
54
|
+
*/
|
|
55
|
+
export function useBranding(): void {
|
|
56
|
+
const branding = useTenantBrandingStore((s) => s.branding);
|
|
57
|
+
const slug = useTenantSlugFromUrl();
|
|
58
|
+
const styleRef = useRef<HTMLStyleElement | null>(null);
|
|
59
|
+
const primedSlugRef = useRef<string | null>(null);
|
|
60
|
+
|
|
61
|
+
// 首帧前用缓存上色,避免默认品牌色闪一下
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
if (!slug || primedSlugRef.current === slug) return;
|
|
64
|
+
primedSlugRef.current = slug;
|
|
65
|
+
const cached = readCachedBranding(slug);
|
|
66
|
+
applyBrandColors(cached?.primaryColor ?? '', cached?.primaryColorDark);
|
|
67
|
+
}, [slug]);
|
|
68
|
+
|
|
69
|
+
useLayoutEffect(() => {
|
|
70
|
+
if (!branding) {
|
|
71
|
+
applyBrandColors('');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
applyBrandColors(branding.primaryColor, branding.primaryColorDark);
|
|
76
|
+
|
|
77
|
+
if (branding.faviconUrl) {
|
|
78
|
+
let favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
|
79
|
+
if (!favicon) {
|
|
80
|
+
favicon = document.createElement('link');
|
|
81
|
+
favicon.rel = 'icon';
|
|
82
|
+
document.head.appendChild(favicon);
|
|
83
|
+
}
|
|
84
|
+
favicon.href = branding.faviconUrl;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (branding.customCss) {
|
|
88
|
+
if (!styleRef.current) {
|
|
89
|
+
styleRef.current = document.createElement('style');
|
|
90
|
+
styleRef.current.setAttribute('data-tenant-css', '');
|
|
91
|
+
document.head.appendChild(styleRef.current);
|
|
92
|
+
}
|
|
93
|
+
styleRef.current.textContent = branding.customCss;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
applyBrandColors('');
|
|
98
|
+
if (styleRef.current) styleRef.current.textContent = '';
|
|
99
|
+
};
|
|
100
|
+
}, [branding]);
|
|
101
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuditStatsOnly — 审计统计摘要组件
|
|
3
|
+
*
|
|
4
|
+
* 在 strict sod_mode 下,admin 角色仅能看到聚合统计卡片,
|
|
5
|
+
* 不能看到审计日志明细行、全文检索、导出等功能。
|
|
6
|
+
*
|
|
7
|
+
* 不使用 antd 依赖(shared 包不直接依赖 antd),
|
|
8
|
+
* 使用纯 Tailwind CSS + HTML 实现轻量提示卡片。
|
|
9
|
+
*
|
|
10
|
+
* @see document/architecture/decisions/007-security-dashboard-audit.md §2.2
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use client';
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
|
|
17
|
+
interface AuditStatsOnlyProps {
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
children?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 审计受限提示组件。
|
|
25
|
+
* 在 strict 模式下替换完整审计页面内容。
|
|
26
|
+
*
|
|
27
|
+
* 可选的 children 用于放置自定义的统计卡片内容。
|
|
28
|
+
*/
|
|
29
|
+
export function AuditStatsOnly({ title, description, children }: AuditStatsOnlyProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex flex-col items-center justify-center min-h-[40vh] p-8">
|
|
32
|
+
<div className="w-full max-w-2xl bg-white dark:bg-slate-800 rounded-lg shadow-card border border-gray-200 dark:border-slate-700 p-8">
|
|
33
|
+
<div className="flex flex-col items-center text-center space-y-4">
|
|
34
|
+
{/* Lock icon via inline SVG (no external dependency) */}
|
|
35
|
+
<svg
|
|
36
|
+
className="w-12 h-12 text-gray-400 dark:text-gray-500"
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
>
|
|
42
|
+
<path
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeLinejoin="round"
|
|
45
|
+
strokeWidth={1.5}
|
|
46
|
+
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
|
47
|
+
/>
|
|
48
|
+
</svg>
|
|
49
|
+
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100 !mb-1">
|
|
50
|
+
{title || '统计摘要'}
|
|
51
|
+
</h4>
|
|
52
|
+
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-md">
|
|
53
|
+
{description ||
|
|
54
|
+
'在当前模式下,您只能查看审计统计摘要。如需查看完整审计日志,请联系您的安全管理员或使用 security-dashboard 的审计员账号登录。'}
|
|
55
|
+
</p>
|
|
56
|
+
{children && (
|
|
57
|
+
<div className="w-full mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { handleOAuthCallback } from '../auth/oauth-login';
|
|
3
|
+
|
|
4
|
+
// 这个组件在 9 个站点各存一份,三处用户可见文案此前**三种写法并存**:
|
|
5
|
+
// admin「正在登录…」 / platform「登录中…」 / 其余 7 站是英文「Signing in...」
|
|
6
|
+
// 也就是说 7 个中文门户在 OAuth 回调时会显示英文文案。
|
|
7
|
+
//
|
|
8
|
+
// 采用与权威 shared/ui/ErrorBoundary.tsx 完全相同的约定:用 <html lang> 判定文案语言,
|
|
9
|
+
// 默认中文——各 portal 的 I18nProvider 都会在 languageChanged 时同步它。
|
|
10
|
+
// (ErrorBoundary 当初就是因为各 portal 的 t() 约定不一致,才改用这个零依赖信号。)
|
|
11
|
+
const TEXT = {
|
|
12
|
+
zh: {
|
|
13
|
+
signingIn: '正在登录…',
|
|
14
|
+
failed: '登录失败',
|
|
15
|
+
retry: '重试',
|
|
16
|
+
// AUTH-46②:OAuthCallbackError.code → 本地化文案(英文裸句不再落用户屏)
|
|
17
|
+
'oauth.provider_error': '身份提供商返回错误,请返回重试',
|
|
18
|
+
'oauth.missing_code': '授权响应缺少授权码,请返回重试',
|
|
19
|
+
'oauth.state_mismatch': '登录状态校验失败(可能为安全原因),请重新登录',
|
|
20
|
+
'oauth.pkce_cleared': '登录页面刷新导致登录凭据丢失,请重新登录',
|
|
21
|
+
generic: '登录失败,请返回重试',
|
|
22
|
+
},
|
|
23
|
+
en: {
|
|
24
|
+
signingIn: 'Signing in...',
|
|
25
|
+
failed: 'Login failed',
|
|
26
|
+
retry: 'Try again',
|
|
27
|
+
'oauth.provider_error': 'The identity provider returned an error. Please go back and retry.',
|
|
28
|
+
'oauth.missing_code': 'The authorization response is missing a code. Please retry.',
|
|
29
|
+
'oauth.state_mismatch': 'Login state verification failed (possibly for security reasons). Please sign in again.',
|
|
30
|
+
'oauth.pkce_cleared': 'The page reload cleared the login credentials. Please sign in again.',
|
|
31
|
+
generic: 'Login failed. Please go back and retry.',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function text(): typeof TEXT.zh {
|
|
36
|
+
if (typeof document === 'undefined') return TEXT.zh;
|
|
37
|
+
const lang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
|
|
38
|
+
return lang.startsWith('zh') ? TEXT.zh : TEXT.en;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** AUTH-46②:按错误 code 取本地化文案;无 code(未知错误)给通用文案并保留原文到 console。 */
|
|
42
|
+
function errorText(t: typeof TEXT.zh, e: unknown): string {
|
|
43
|
+
const code = (e as { code?: string })?.code;
|
|
44
|
+
if (code && (t as Record<string, string>)[code]) return (t as Record<string, string>)[code];
|
|
45
|
+
console.error('[oauth-callback]', e);
|
|
46
|
+
return t.generic;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function OAuthCallbackPage() {
|
|
50
|
+
const [error, setError] = useState('');
|
|
51
|
+
const [loading, setLoading] = useState(true);
|
|
52
|
+
const t = text();
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
handleOAuthCallback().catch((e) => {
|
|
56
|
+
setError(errorText(t, e));
|
|
57
|
+
setLoading(false);
|
|
58
|
+
});
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
if (loading) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex h-screen items-center justify-center bg-neutral-50">
|
|
64
|
+
<div className="flex flex-col items-center gap-3">
|
|
65
|
+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
|
66
|
+
<p className="text-sm text-neutral-500">{t.signingIn}</p>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="flex h-screen items-center justify-center bg-neutral-50">
|
|
74
|
+
<div className="max-w-sm rounded-lg border border-red-200 bg-red-50 p-6 text-center">
|
|
75
|
+
<p className="text-sm font-medium text-red-700">{t.failed}</p>
|
|
76
|
+
<p className="mt-1 text-xs text-red-600">{error}</p>
|
|
77
|
+
<a href="/" className="mt-4 inline-block text-sm text-blue-600 underline">
|
|
78
|
+
{t.retry}
|
|
79
|
+
</a>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|