@reflagged/shell 1.4.0 → 1.5.1
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/README.md +26 -0
- package/package.json +2 -1
- package/src/components/BrandSwitcher.tsx +56 -22
- package/src/components/TenantTheme.tsx +49 -0
package/README.md
CHANGED
|
@@ -132,6 +132,32 @@ import { NotificationBell } from '@reflagged/shell/components/NotificationBell'
|
|
|
132
132
|
<NotificationBell />
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
## Tenant branding
|
|
136
|
+
|
|
137
|
+
`BrandSwitcher` shows the tenant's logo mark and loads the tenant palette from
|
|
138
|
+
the platform (`org.themeCssUrl` in `/api/shell-info`) as
|
|
139
|
+
`<link id="rflgd-tenant-theme">`. Modules without the switcher render
|
|
140
|
+
`<TenantTheme />` from `@reflagged/shell/components/TenantTheme` once in the root
|
|
141
|
+
layout.
|
|
142
|
+
|
|
143
|
+
The stylesheet defines, on `:root`:
|
|
144
|
+
|
|
145
|
+
- `--brass-50` … `--brass-900` — RGB channels (`125 85 26`), the Reflagged
|
|
146
|
+
brass scale re-tinted to the tenant colour. Every step keeps brass's
|
|
147
|
+
luminance, so contrast pairs that work with brass keep working.
|
|
148
|
+
- `--rflgd-brand-primary` — the raw tenant colour (decorative use only).
|
|
149
|
+
|
|
150
|
+
Point the module's own tokens at it, always with the brass fallback:
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
:root { --primary: rgb(var(--brass-700, 125 85 26)); }
|
|
154
|
+
.dark { --primary: rgb(var(--brass-400, 214 166 76)); }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
or in Tailwind 3: `primary: 'rgb(var(--brass-700, 125 85 26) / <alpha-value>)'`.
|
|
158
|
+
Text on a filled brand surface stays white on `700` (light) and dark on `400`
|
|
159
|
+
(dark mode). Status and destructive colours do not follow the palette.
|
|
160
|
+
|
|
135
161
|
## Local development (live-edit against a consumer)
|
|
136
162
|
|
|
137
163
|
A published package is frozen in `node_modules`. To iterate on the shell and a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reflagged/shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./middleware": "./src/middleware.ts",
|
|
18
18
|
"./config": "./src/config.ts",
|
|
19
19
|
"./components/BrandSwitcher": "./src/components/BrandSwitcher.tsx",
|
|
20
|
+
"./components/TenantTheme": "./src/components/TenantTheme.tsx",
|
|
20
21
|
"./auth/oidc-config": "./src/lib/auth/oidc-config.ts",
|
|
21
22
|
"./auth/oidc-cookie": "./src/lib/auth/oidc-cookie.ts",
|
|
22
23
|
"./auth/can-access": "./src/lib/auth/can-access.ts",
|
|
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
4
4
|
import { createPortal } from 'react-dom'
|
|
5
5
|
import { loadAppConfig } from '../config'
|
|
6
6
|
import { ServiceIcon } from './ServiceIcon'
|
|
7
|
+
import { useTenantTheme } from './TenantTheme'
|
|
7
8
|
|
|
8
9
|
type Booking = {
|
|
9
10
|
id: string
|
|
@@ -25,6 +26,11 @@ type Org = {
|
|
|
25
26
|
slug: string
|
|
26
27
|
name: string
|
|
27
28
|
logoUrl: string | null
|
|
29
|
+
/** Tenant branding from rflgd-base ≥ Oct 2026; older platforms omit them. */
|
|
30
|
+
logoDarkUrl?: string | null
|
|
31
|
+
logoMarkUrl?: string | null
|
|
32
|
+
/** Tenant palette stylesheet (`--brass-50…900`, `--rflgd-brand-primary`). */
|
|
33
|
+
themeCssUrl?: string | null
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
type ShellInfo = {
|
|
@@ -36,6 +42,15 @@ type ShellInfo = {
|
|
|
36
42
|
baseUrl: string
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Colours follow the tenant palette when the platform's theme.css is loaded
|
|
47
|
+
* (`--rflgd-brand-primary`, `--brass-700`), and fall back to Reflagged brass.
|
|
48
|
+
*/
|
|
49
|
+
const BRAND = 'var(--rflgd-brand-primary, #B09A6A)'
|
|
50
|
+
const BRAND_TEXT = 'rgb(var(--brass-700, 125 85 26))'
|
|
51
|
+
const tint = (percent: number) =>
|
|
52
|
+
`color-mix(in srgb, var(--rflgd-brand-primary, #B09A6A) ${percent}%, transparent)`
|
|
53
|
+
|
|
39
54
|
const StackIcon = ({ size = 22 }: { size?: number }) => (
|
|
40
55
|
<svg
|
|
41
56
|
width={size}
|
|
@@ -45,16 +60,33 @@ const StackIcon = ({ size = 22 }: { size?: number }) => (
|
|
|
45
60
|
aria-hidden
|
|
46
61
|
style={{ flexShrink: 0 }}
|
|
47
62
|
>
|
|
48
|
-
<path d="M32,0 H23 A3,3 0 0,0 20,3 A3,3 0 0,0 23,6 H32 Z"
|
|
49
|
-
<path d="M32,11 H12 A2,2 0 0,0 10,13 A2,2 0 0,0 12,15 H32 Z"
|
|
63
|
+
<path d="M32,0 H23 A3,3 0 0,0 20,3 A3,3 0 0,0 23,6 H32 Z" style={{ fill: BRAND }} />
|
|
64
|
+
<path d="M32,11 H12 A2,2 0 0,0 10,13 A2,2 0 0,0 12,15 H32 Z" style={{ fill: BRAND }} opacity="0.45" />
|
|
50
65
|
<path
|
|
51
66
|
d="M32,23 H1.5 A1.5,1.5 0 0,0 0,24.5 A1.5,1.5 0 0,0 1.5,26 H32 Z"
|
|
52
|
-
fill
|
|
67
|
+
style={{ fill: BRAND }}
|
|
53
68
|
opacity="0.25"
|
|
54
69
|
/>
|
|
55
70
|
</svg>
|
|
56
71
|
)
|
|
57
72
|
|
|
73
|
+
/** The tenant's logo mark (square) when it has one, else the Reflagged stack. */
|
|
74
|
+
function BrandMark({ org, size }: { org: Org | null | undefined; size: number }) {
|
|
75
|
+
const [failed, setFailed] = useState(false)
|
|
76
|
+
const src = org?.logoMarkUrl ?? null
|
|
77
|
+
if (!src || failed) return <StackIcon size={size} />
|
|
78
|
+
return (
|
|
79
|
+
<img
|
|
80
|
+
src={src}
|
|
81
|
+
alt=""
|
|
82
|
+
width={size}
|
|
83
|
+
height={size}
|
|
84
|
+
onError={() => setFailed(true)}
|
|
85
|
+
style={{ width: size, height: size, objectFit: 'contain', borderRadius: 4, flexShrink: 0 }}
|
|
86
|
+
/>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
export function BrandSwitcher() {
|
|
59
91
|
const config = loadAppConfig()
|
|
60
92
|
const [shell, setShell] = useState<ShellInfo | null>(null)
|
|
@@ -120,6 +152,8 @@ export function BrandSwitcher() {
|
|
|
120
152
|
return () => document.removeEventListener('mousedown', onClick)
|
|
121
153
|
}, [open])
|
|
122
154
|
|
|
155
|
+
useTenantTheme(shell?.org?.themeCssUrl)
|
|
156
|
+
|
|
123
157
|
const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
|
|
124
158
|
const orgName = shell?.org?.name ?? null
|
|
125
159
|
const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
|
|
@@ -147,7 +181,7 @@ export function BrandSwitcher() {
|
|
|
147
181
|
color: 'inherit',
|
|
148
182
|
}}
|
|
149
183
|
>
|
|
150
|
-
<
|
|
184
|
+
<BrandMark org={shell?.org} size={22} />
|
|
151
185
|
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.15 }}>
|
|
152
186
|
<div
|
|
153
187
|
style={{
|
|
@@ -223,10 +257,10 @@ export function BrandSwitcher() {
|
|
|
223
257
|
marginTop: pos ? 0 : 8,
|
|
224
258
|
width: 280,
|
|
225
259
|
borderRadius: 14,
|
|
226
|
-
border:
|
|
260
|
+
border: `1px solid ${tint(16)}`,
|
|
227
261
|
background: '#FFFFFF',
|
|
228
262
|
padding: 8,
|
|
229
|
-
boxShadow:
|
|
263
|
+
boxShadow: `0 1px 0 ${tint(8)}, 0 12px 40px rgba(0,0,0,0.15)`,
|
|
230
264
|
}}
|
|
231
265
|
>
|
|
232
266
|
{showWorkspaceSection ? (
|
|
@@ -243,7 +277,7 @@ export function BrandSwitcher() {
|
|
|
243
277
|
gap: 12,
|
|
244
278
|
padding: '8px 12px',
|
|
245
279
|
borderRadius: 8,
|
|
246
|
-
background: isCurrent ?
|
|
280
|
+
background: isCurrent ? tint(15) : 'transparent',
|
|
247
281
|
}}
|
|
248
282
|
>
|
|
249
283
|
<div
|
|
@@ -251,8 +285,8 @@ export function BrandSwitcher() {
|
|
|
251
285
|
width: 32,
|
|
252
286
|
height: 32,
|
|
253
287
|
borderRadius: 8,
|
|
254
|
-
background:
|
|
255
|
-
color:
|
|
288
|
+
background: tint(15),
|
|
289
|
+
color: BRAND_TEXT,
|
|
256
290
|
display: 'flex',
|
|
257
291
|
alignItems: 'center',
|
|
258
292
|
justifyContent: 'center',
|
|
@@ -275,9 +309,9 @@ export function BrandSwitcher() {
|
|
|
275
309
|
style={{
|
|
276
310
|
fontFamily: "'JetBrains Mono', monospace",
|
|
277
311
|
fontSize: 9.5,
|
|
278
|
-
color:
|
|
312
|
+
color: BRAND_TEXT,
|
|
279
313
|
padding: '2px 6px',
|
|
280
|
-
background:
|
|
314
|
+
background: tint(15),
|
|
281
315
|
borderRadius: 4,
|
|
282
316
|
textTransform: 'uppercase',
|
|
283
317
|
letterSpacing: '0.1em',
|
|
@@ -292,7 +326,7 @@ export function BrandSwitcher() {
|
|
|
292
326
|
<hr
|
|
293
327
|
style={{
|
|
294
328
|
border: 'none',
|
|
295
|
-
borderTop:
|
|
329
|
+
borderTop: `1px solid ${tint(12)}`,
|
|
296
330
|
margin: '6px 0',
|
|
297
331
|
}}
|
|
298
332
|
/>
|
|
@@ -305,7 +339,7 @@ export function BrandSwitcher() {
|
|
|
305
339
|
href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
|
|
306
340
|
typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
|
|
307
341
|
)}`}
|
|
308
|
-
style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color:
|
|
342
|
+
style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
309
343
|
>
|
|
310
344
|
Anmelden, um Services zu sehen →
|
|
311
345
|
</a>
|
|
@@ -331,7 +365,7 @@ export function BrandSwitcher() {
|
|
|
331
365
|
width: 32,
|
|
332
366
|
height: 32,
|
|
333
367
|
borderRadius: 8,
|
|
334
|
-
background:
|
|
368
|
+
background: tint(10),
|
|
335
369
|
display: 'flex',
|
|
336
370
|
alignItems: 'center',
|
|
337
371
|
justifyContent: 'center',
|
|
@@ -340,7 +374,7 @@ export function BrandSwitcher() {
|
|
|
340
374
|
<StackIcon size={18} />
|
|
341
375
|
</div>
|
|
342
376
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
343
|
-
<div style={{ fontWeight: 500, fontSize: 13.5 }}>
|
|
377
|
+
<div style={{ fontWeight: 500, fontSize: 13.5 }}>Base</div>
|
|
344
378
|
<div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
|
|
345
379
|
Katalog & Settings
|
|
346
380
|
</div>
|
|
@@ -351,7 +385,7 @@ export function BrandSwitcher() {
|
|
|
351
385
|
fontSize: 9.5,
|
|
352
386
|
color: '#7A6A58',
|
|
353
387
|
padding: '2px 6px',
|
|
354
|
-
background:
|
|
388
|
+
background: tint(8),
|
|
355
389
|
borderRadius: 4,
|
|
356
390
|
textTransform: 'uppercase',
|
|
357
391
|
letterSpacing: '0.1em',
|
|
@@ -381,7 +415,7 @@ export function BrandSwitcher() {
|
|
|
381
415
|
borderRadius: 8,
|
|
382
416
|
textDecoration: 'none',
|
|
383
417
|
color: '#2C1E14',
|
|
384
|
-
background: active ?
|
|
418
|
+
background: active ? tint(15) : 'transparent',
|
|
385
419
|
}}
|
|
386
420
|
>
|
|
387
421
|
<div
|
|
@@ -389,8 +423,8 @@ export function BrandSwitcher() {
|
|
|
389
423
|
width: 32,
|
|
390
424
|
height: 32,
|
|
391
425
|
borderRadius: 8,
|
|
392
|
-
background: active ?
|
|
393
|
-
color: active ?
|
|
426
|
+
background: active ? tint(25) : 'rgba(176,138,122,0.25)',
|
|
427
|
+
color: active ? BRAND_TEXT : '#B08A7A',
|
|
394
428
|
display: 'flex',
|
|
395
429
|
alignItems: 'center',
|
|
396
430
|
justifyContent: 'center',
|
|
@@ -411,9 +445,9 @@ export function BrandSwitcher() {
|
|
|
411
445
|
style={{
|
|
412
446
|
fontFamily: "'JetBrains Mono', monospace",
|
|
413
447
|
fontSize: 9.5,
|
|
414
|
-
color:
|
|
448
|
+
color: BRAND_TEXT,
|
|
415
449
|
padding: '2px 6px',
|
|
416
|
-
background:
|
|
450
|
+
background: tint(15),
|
|
417
451
|
borderRadius: 4,
|
|
418
452
|
textTransform: 'uppercase',
|
|
419
453
|
letterSpacing: '0.1em',
|
|
@@ -448,7 +482,7 @@ export function BrandSwitcher() {
|
|
|
448
482
|
<hr
|
|
449
483
|
style={{
|
|
450
484
|
border: 'none',
|
|
451
|
-
borderTop:
|
|
485
|
+
borderTop: `1px solid ${tint(12)}`,
|
|
452
486
|
margin: '6px 0',
|
|
453
487
|
}}
|
|
454
488
|
/>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
const THEME_LINK_ID = 'rflgd-tenant-theme'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Loads the tenant palette stylesheet from the platform once per page:
|
|
9
|
+
* `--brass-50` … `--brass-900` (RGB channels) and `--rflgd-brand-primary`.
|
|
10
|
+
* Modules point their own tokens at it with a brass fallback, e.g.
|
|
11
|
+
* `--primary: rgb(var(--brass-700, 125 85 26))`.
|
|
12
|
+
*/
|
|
13
|
+
export function useTenantTheme(href: string | null | undefined): void {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!href) return
|
|
16
|
+
const existing = document.getElementById(THEME_LINK_ID) as HTMLLinkElement | null
|
|
17
|
+
if (existing) {
|
|
18
|
+
if (existing.href !== href) existing.href = href
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
const link = document.createElement('link')
|
|
22
|
+
link.id = THEME_LINK_ID
|
|
23
|
+
link.rel = 'stylesheet'
|
|
24
|
+
link.href = href
|
|
25
|
+
document.head.appendChild(link)
|
|
26
|
+
}, [href])
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* For modules that do not mount `BrandSwitcher` (which loads the palette
|
|
31
|
+
* itself): render once in the root layout.
|
|
32
|
+
*/
|
|
33
|
+
export function TenantTheme(): null {
|
|
34
|
+
const [href, setHref] = useState<string | null>(null)
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let cancel = false
|
|
37
|
+
fetch('/api/shell-info', { credentials: 'include' })
|
|
38
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
39
|
+
.then((d: { org?: { themeCssUrl?: string | null } | null } | null) => {
|
|
40
|
+
if (!cancel) setHref(d?.org?.themeCssUrl ?? null)
|
|
41
|
+
})
|
|
42
|
+
.catch(() => undefined)
|
|
43
|
+
return () => {
|
|
44
|
+
cancel = true
|
|
45
|
+
}
|
|
46
|
+
}, [])
|
|
47
|
+
useTenantTheme(href)
|
|
48
|
+
return null
|
|
49
|
+
}
|