@reflagged/shell 1.5.1 → 1.6.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 +11 -1
- package/package.json +1 -1
- package/src/components/BrandSwitcher.tsx +390 -298
- package/src/lib/palette/palette.ts +36 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Shared app shell for Reflagged services. Provides:
|
|
|
5
5
|
- OIDC authentication (signin, callback, signout)
|
|
6
6
|
- SSO session management (JWT cookie + Payload AuthStrategy)
|
|
7
7
|
- `/api/shell-info` proxy for platform service catalog
|
|
8
|
-
- `BrandSwitcher` component (app
|
|
8
|
+
- `BrandSwitcher` component (app palette: icons, search, ⌘K / Ctrl+K)
|
|
9
9
|
- Admin route SSO enforcement middleware
|
|
10
10
|
|
|
11
11
|
Published as a **public npm package of raw TS/TSX source**. Consumers transpile
|
|
@@ -132,6 +132,16 @@ import { NotificationBell } from '@reflagged/shell/components/NotificationBell'
|
|
|
132
132
|
<NotificationBell />
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
## App palette
|
|
136
|
+
|
|
137
|
+
`BrandSwitcher` opens on click or with ⌘K / Ctrl+K anywhere in the app. It
|
|
138
|
+
shows every running app with its catalog icon and tagline, filters them as you
|
|
139
|
+
type, and lists the user's workspaces. Platform admins get a "Plattform"
|
|
140
|
+
section that links to the Base palette (`<baseUrl>/?palette=<query>`); the
|
|
141
|
+
tenant switch itself runs in Base, where the platform session lives.
|
|
142
|
+
|
|
143
|
+
Apps that bind ⌘K / Ctrl+K themselves should drop their own binding.
|
|
144
|
+
|
|
135
145
|
## Tenant branding
|
|
136
146
|
|
|
137
147
|
`BrandSwitcher` shows the tenant's logo mark and loads the tenant palette from
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
|
4
4
|
import { createPortal } from 'react-dom'
|
|
5
5
|
import { loadAppConfig } from '../config'
|
|
6
|
+
import { baseSearchUrl, isPaletteShortcut, matchApps, type PaletteApp } from '../lib/palette/palette'
|
|
6
7
|
import { ServiceIcon } from './ServiceIcon'
|
|
7
8
|
import { useTenantTheme } from './TenantTheme'
|
|
8
9
|
|
|
@@ -34,10 +35,12 @@ type Org = {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
type ShellInfo = {
|
|
37
|
-
user: { email: string; name?: string | null }
|
|
38
|
+
user: { email: string; name?: string | null; role?: string }
|
|
38
39
|
org: Org | null
|
|
39
40
|
orgs?: Org[]
|
|
40
41
|
bookings: Booking[]
|
|
42
|
+
/** Catalog entries; only `tagline` is read here (rflgd-base ≥ Oct 2026). */
|
|
43
|
+
catalog?: Array<{ service: string; tagline: string | null }>
|
|
41
44
|
catalogUrl: string
|
|
42
45
|
baseUrl: string
|
|
43
46
|
}
|
|
@@ -87,17 +90,67 @@ function BrandMark({ org, size }: { org: Org | null | undefined; size: number })
|
|
|
87
90
|
)
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
const INK = '#2C1E14'
|
|
94
|
+
const MUTED = '#7A6A58'
|
|
95
|
+
const MONO = "'JetBrains Mono', monospace"
|
|
96
|
+
const PLATE: React.CSSProperties = {
|
|
97
|
+
display: 'grid',
|
|
98
|
+
placeItems: 'center',
|
|
99
|
+
color: BRAND_TEXT,
|
|
100
|
+
background: 'linear-gradient(180deg, #fffdf6 0%, #f7edd6 52%, #f0e2c2 100%)',
|
|
101
|
+
boxShadow:
|
|
102
|
+
'inset 0 1px 0 rgba(255,255,255,0.95), 0 0 0 1px rgba(125,85,26,0.13), 0 5px 12px -5px rgba(58,40,12,0.32)',
|
|
103
|
+
flexShrink: 0,
|
|
104
|
+
}
|
|
105
|
+
const LABEL: React.CSSProperties = {
|
|
106
|
+
fontFamily: MONO,
|
|
107
|
+
fontSize: 10,
|
|
108
|
+
color: MUTED,
|
|
109
|
+
textTransform: 'uppercase',
|
|
110
|
+
letterSpacing: '0.12em',
|
|
111
|
+
}
|
|
112
|
+
const KBD: React.CSSProperties = {
|
|
113
|
+
fontFamily: MONO,
|
|
114
|
+
fontSize: 10,
|
|
115
|
+
color: MUTED,
|
|
116
|
+
border: '1px solid rgba(44,30,20,0.14)',
|
|
117
|
+
borderRadius: 4,
|
|
118
|
+
padding: '0 5px',
|
|
119
|
+
whiteSpace: 'nowrap',
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function AppGlyph({ app, size }: { app: PaletteApp; size: number }) {
|
|
123
|
+
return app.icon === null ? <StackIcon size={size} /> : <ServiceIcon name={app.icon} label={app.label} size={size} />
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Logo-as-switcher. One click on the trigger, or ⌘K / Ctrl+K anywhere,
|
|
128
|
+
* opens the app palette (design "C · Spotlight", same as rflgd-base):
|
|
129
|
+
* every app as an icon plate with its catalog tagline, a search across
|
|
130
|
+
* apps, the user's workspaces, and logout.
|
|
131
|
+
*
|
|
132
|
+
* Platform admins also get a "Plattform" section. Switching a platform
|
|
133
|
+
* tenant runs on Base's session, so it links to the Base palette with the
|
|
134
|
+
* query prefilled instead of switching here.
|
|
135
|
+
*/
|
|
90
136
|
export function BrandSwitcher() {
|
|
91
137
|
const config = loadAppConfig()
|
|
92
138
|
const [shell, setShell] = useState<ShellInfo | null>(null)
|
|
93
139
|
const [error, setError] = useState(false)
|
|
94
140
|
const [open, setOpen] = useState(false)
|
|
141
|
+
const [query, setQuery] = useState('')
|
|
142
|
+
const [active, setActive] = useState(0)
|
|
95
143
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
96
144
|
const [mounted, setMounted] = useState(false)
|
|
145
|
+
const [shortcut, setShortcut] = useState('Strg K')
|
|
97
146
|
const triggerRef = useRef<HTMLDivElement>(null)
|
|
147
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
148
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
149
|
+
const listId = useId()
|
|
98
150
|
|
|
99
151
|
useEffect(() => {
|
|
100
152
|
setMounted(true)
|
|
153
|
+
if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) setShortcut('⌘K')
|
|
101
154
|
}, [])
|
|
102
155
|
|
|
103
156
|
useEffect(() => {
|
|
@@ -119,55 +172,113 @@ export function BrandSwitcher() {
|
|
|
119
172
|
}
|
|
120
173
|
}, [])
|
|
121
174
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
175
|
+
const place = () => {
|
|
176
|
+
const r = triggerRef.current?.getBoundingClientRect()
|
|
177
|
+
if (r) setPos({ top: r.bottom + 4, left: Math.max(8, Math.min(r.left, window.innerWidth - 648)) })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const toggle = () => {
|
|
181
|
+
if (!open) place()
|
|
127
182
|
setOpen((v) => !v)
|
|
128
183
|
}
|
|
129
184
|
|
|
130
|
-
//
|
|
185
|
+
// Global shortcut.
|
|
131
186
|
useEffect(() => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
187
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
188
|
+
if (!isPaletteShortcut(e)) return
|
|
189
|
+
// Apps mount the switcher twice (mobile header, desktop sidebar) and hide
|
|
190
|
+
// one with CSS; only the visible one answers, or both palettes open.
|
|
191
|
+
if (!triggerRef.current?.getClientRects().length) return
|
|
192
|
+
e.preventDefault()
|
|
193
|
+
place()
|
|
194
|
+
setOpen((v) => !v)
|
|
136
195
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
window.removeEventListener('resize', update)
|
|
141
|
-
window.removeEventListener('scroll', update, true)
|
|
142
|
-
}
|
|
143
|
-
}, [open])
|
|
196
|
+
document.addEventListener('keydown', onKeyDown)
|
|
197
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
198
|
+
}, [])
|
|
144
199
|
|
|
200
|
+
// While open: follow the trigger, close on outside click, focus the search.
|
|
145
201
|
useEffect(() => {
|
|
146
|
-
if (!open)
|
|
202
|
+
if (!open) {
|
|
203
|
+
setQuery('')
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
requestAnimationFrame(() => inputRef.current?.focus())
|
|
147
207
|
const onClick = (e: MouseEvent) => {
|
|
148
208
|
if ((e.target as HTMLElement)?.closest('[data-brand-switcher]')) return
|
|
149
209
|
setOpen(false)
|
|
150
210
|
}
|
|
211
|
+
window.addEventListener('resize', place)
|
|
212
|
+
window.addEventListener('scroll', place, true)
|
|
151
213
|
document.addEventListener('mousedown', onClick)
|
|
152
|
-
return () =>
|
|
214
|
+
return () => {
|
|
215
|
+
window.removeEventListener('resize', place)
|
|
216
|
+
window.removeEventListener('scroll', place, true)
|
|
217
|
+
document.removeEventListener('mousedown', onClick)
|
|
218
|
+
}
|
|
153
219
|
}, [open])
|
|
154
220
|
|
|
155
221
|
useTenantTheme(shell?.org?.themeCssUrl)
|
|
156
222
|
|
|
157
|
-
const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
|
|
158
223
|
const orgName = shell?.org?.name ?? null
|
|
159
224
|
const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
|
|
160
225
|
const orgs = shell?.orgs ?? (shell?.org ? [shell.org] : [])
|
|
161
|
-
const
|
|
226
|
+
const isPlatformAdmin = shell?.user.role === 'superadmin' || shell?.user.role === 'reflagged_admin'
|
|
227
|
+
|
|
228
|
+
const apps = useMemo<PaletteApp[]>(() => {
|
|
229
|
+
const taglines = new Map((shell?.catalog ?? []).map((c) => [c.service, c.tagline]))
|
|
230
|
+
const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
|
|
231
|
+
return [
|
|
232
|
+
{ key: 'base', label: 'Base', tagline: 'Start, Team und Einstellungen', icon: null, href: baseUrl, current: false },
|
|
233
|
+
...ready.map((b) => ({
|
|
234
|
+
key: b.service,
|
|
235
|
+
label: b.label,
|
|
236
|
+
tagline: taglines.get(b.service) ?? null,
|
|
237
|
+
icon: b.icon ?? '',
|
|
238
|
+
href: b.url as string,
|
|
239
|
+
current: b.service === config.appKey,
|
|
240
|
+
})),
|
|
241
|
+
]
|
|
242
|
+
}, [shell, baseUrl, config.appKey])
|
|
243
|
+
|
|
244
|
+
const hits = useMemo(() => matchApps(apps, query), [apps, query])
|
|
245
|
+
const hasQuery = query.trim().length > 0
|
|
246
|
+
// The platform jump is the last row while searching, so ↓ reaches it.
|
|
247
|
+
const rowCount = hits.length + (hasQuery && isPlatformAdmin ? 1 : 0)
|
|
248
|
+
|
|
249
|
+
useEffect(() => setActive(0), [query])
|
|
250
|
+
|
|
251
|
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
252
|
+
if (e.key === 'ArrowDown') {
|
|
253
|
+
e.preventDefault()
|
|
254
|
+
setActive((i) => Math.min(i + 1, rowCount - 1))
|
|
255
|
+
} else if (e.key === 'ArrowUp') {
|
|
256
|
+
e.preventDefault()
|
|
257
|
+
setActive((i) => Math.max(i - 1, 0))
|
|
258
|
+
} else if (e.key === 'Enter' && hasQuery) {
|
|
259
|
+
e.preventDefault()
|
|
260
|
+
const hit = hits[active]
|
|
261
|
+
if (hit) window.location.assign(hit.href)
|
|
262
|
+
else if (isPlatformAdmin) window.location.assign(baseSearchUrl(baseUrl, query))
|
|
263
|
+
} else if (e.key === 'Escape') {
|
|
264
|
+
e.preventDefault()
|
|
265
|
+
if (query) setQuery('')
|
|
266
|
+
else {
|
|
267
|
+
setOpen(false)
|
|
268
|
+
buttonRef.current?.focus()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
162
272
|
|
|
163
273
|
const trigger = (
|
|
164
274
|
<div ref={triggerRef}>
|
|
165
275
|
<button
|
|
276
|
+
ref={buttonRef}
|
|
166
277
|
type="button"
|
|
167
|
-
onClick={
|
|
168
|
-
aria-haspopup="
|
|
278
|
+
onClick={toggle}
|
|
279
|
+
aria-haspopup="dialog"
|
|
169
280
|
aria-expanded={open}
|
|
170
|
-
aria-label="
|
|
281
|
+
aria-label="Apps wechseln"
|
|
171
282
|
style={{
|
|
172
283
|
display: 'flex',
|
|
173
284
|
alignItems: 'center',
|
|
@@ -182,7 +293,7 @@ export function BrandSwitcher() {
|
|
|
182
293
|
}}
|
|
183
294
|
>
|
|
184
295
|
<BrandMark org={shell?.org} size={22} />
|
|
185
|
-
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.15 }}>
|
|
296
|
+
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.15, textAlign: 'left' }}>
|
|
186
297
|
<div
|
|
187
298
|
style={{
|
|
188
299
|
fontFamily: "'Red Hat Display', system-ui, sans-serif",
|
|
@@ -192,17 +303,17 @@ export function BrandSwitcher() {
|
|
|
192
303
|
whiteSpace: 'nowrap',
|
|
193
304
|
overflow: 'hidden',
|
|
194
305
|
textOverflow: 'ellipsis',
|
|
195
|
-
color:
|
|
306
|
+
color: INK,
|
|
196
307
|
}}
|
|
197
308
|
>
|
|
198
309
|
{config.appLabel}
|
|
199
310
|
</div>
|
|
200
311
|
<div
|
|
201
312
|
style={{
|
|
202
|
-
fontFamily:
|
|
313
|
+
fontFamily: MONO,
|
|
203
314
|
fontSize: 10.5,
|
|
204
315
|
letterSpacing: '0.1em',
|
|
205
|
-
color:
|
|
316
|
+
color: MUTED,
|
|
206
317
|
marginTop: 2,
|
|
207
318
|
whiteSpace: 'nowrap',
|
|
208
319
|
overflow: 'hidden',
|
|
@@ -212,300 +323,281 @@ export function BrandSwitcher() {
|
|
|
212
323
|
{orgName ? `${orgName} · rfl.gd` : 'rfl.gd'}
|
|
213
324
|
</div>
|
|
214
325
|
</div>
|
|
215
|
-
{
|
|
216
|
-
<span
|
|
217
|
-
style={{
|
|
218
|
-
fontSize: 10,
|
|
219
|
-
textTransform: 'uppercase',
|
|
220
|
-
letterSpacing: '0.06em',
|
|
221
|
-
padding: '2px 8px',
|
|
222
|
-
borderRadius: 99,
|
|
223
|
-
border: '1px solid rgba(44,30,20,0.1)',
|
|
224
|
-
background: 'rgba(44,30,20,0.03)',
|
|
225
|
-
color: '#7A6A58',
|
|
226
|
-
whiteSpace: 'nowrap',
|
|
227
|
-
}}
|
|
228
|
-
>
|
|
229
|
-
{orgName}
|
|
230
|
-
</span>
|
|
231
|
-
) : null}
|
|
232
|
-
<span aria-hidden style={{ color: '#7A6A58', fontSize: 10, marginLeft: 4 }}>
|
|
233
|
-
{open ? '▴' : '▾'}
|
|
234
|
-
</span>
|
|
326
|
+
<span style={{ ...KBD, marginLeft: 4 }}>{shortcut}</span>
|
|
235
327
|
</button>
|
|
236
328
|
</div>
|
|
237
329
|
)
|
|
238
330
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
331
|
+
const tile = (app: PaletteApp) => (
|
|
332
|
+
<a
|
|
333
|
+
key={app.key}
|
|
334
|
+
href={app.href}
|
|
335
|
+
aria-current={app.current ? 'page' : undefined}
|
|
336
|
+
data-testid={`brand-switcher-app-${app.key}`}
|
|
337
|
+
style={{
|
|
338
|
+
position: 'relative',
|
|
339
|
+
display: 'flex',
|
|
340
|
+
flexDirection: 'column',
|
|
341
|
+
alignItems: 'center',
|
|
342
|
+
gap: 6,
|
|
343
|
+
padding: '12px 8px',
|
|
344
|
+
borderRadius: 12,
|
|
345
|
+
textDecoration: 'none',
|
|
346
|
+
textAlign: 'center',
|
|
347
|
+
color: INK,
|
|
348
|
+
background: app.current ? tint(15) : 'transparent',
|
|
349
|
+
}}
|
|
350
|
+
>
|
|
351
|
+
{app.current ? (
|
|
352
|
+
<span style={{ ...LABEL, position: 'absolute', top: 8, right: 8, fontSize: 9, color: BRAND_TEXT }}>Hier</span>
|
|
353
|
+
) : null}
|
|
354
|
+
<span aria-hidden style={{ ...PLATE, width: 48, height: 48, borderRadius: 14 }}>
|
|
355
|
+
<AppGlyph app={app} size={22} />
|
|
356
|
+
</span>
|
|
357
|
+
<span style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.2 }}>{app.label}</span>
|
|
358
|
+
{app.tagline ? (
|
|
359
|
+
<span
|
|
360
|
+
style={{
|
|
361
|
+
fontSize: 11.5,
|
|
362
|
+
lineHeight: 1.3,
|
|
363
|
+
color: MUTED,
|
|
364
|
+
display: '-webkit-box',
|
|
365
|
+
WebkitLineClamp: 2,
|
|
366
|
+
WebkitBoxOrient: 'vertical',
|
|
367
|
+
overflow: 'hidden',
|
|
368
|
+
}}
|
|
369
|
+
>
|
|
370
|
+
{app.tagline}
|
|
371
|
+
</span>
|
|
372
|
+
) : null}
|
|
373
|
+
</a>
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
const row = (key: string, index: number, href: string, icon: React.ReactNode, title: string, sub: string) => (
|
|
377
|
+
<a
|
|
378
|
+
key={key}
|
|
379
|
+
id={`${listId}-${index}`}
|
|
380
|
+
role="option"
|
|
381
|
+
aria-selected={index === active}
|
|
382
|
+
href={href}
|
|
383
|
+
onMouseEnter={() => setActive(index)}
|
|
384
|
+
style={{
|
|
385
|
+
display: 'flex',
|
|
386
|
+
alignItems: 'center',
|
|
387
|
+
gap: 12,
|
|
388
|
+
padding: 8,
|
|
389
|
+
borderRadius: 10,
|
|
390
|
+
textDecoration: 'none',
|
|
391
|
+
color: INK,
|
|
392
|
+
background: index === active ? 'rgba(44,30,20,0.05)' : 'transparent',
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
395
|
+
{icon}
|
|
396
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
397
|
+
<span style={{ display: 'block', fontSize: 13.5, fontWeight: 600 }}>{title}</span>
|
|
398
|
+
<span
|
|
399
|
+
style={{
|
|
400
|
+
display: 'block',
|
|
401
|
+
fontSize: 12,
|
|
402
|
+
color: MUTED,
|
|
403
|
+
whiteSpace: 'nowrap',
|
|
404
|
+
overflow: 'hidden',
|
|
405
|
+
textOverflow: 'ellipsis',
|
|
406
|
+
}}
|
|
407
|
+
>
|
|
408
|
+
{sub}
|
|
409
|
+
</span>
|
|
410
|
+
</span>
|
|
411
|
+
{index === active ? <span style={{ ...KBD, border: 'none' }}>↵</span> : null}
|
|
412
|
+
</a>
|
|
413
|
+
)
|
|
247
414
|
|
|
248
415
|
const popover = open ? (
|
|
249
416
|
<div
|
|
250
|
-
role="
|
|
417
|
+
role="dialog"
|
|
418
|
+
aria-label="Apps und Workspaces"
|
|
251
419
|
data-brand-switcher
|
|
420
|
+
data-testid="brand-switcher-panel"
|
|
252
421
|
style={{
|
|
253
422
|
position: pos ? 'fixed' : 'absolute',
|
|
254
423
|
left: pos ? pos.left : 0,
|
|
255
424
|
top: pos ? pos.top : '100%',
|
|
256
425
|
zIndex: 2147483647,
|
|
257
426
|
marginTop: pos ? 0 : 8,
|
|
258
|
-
width:
|
|
259
|
-
borderRadius:
|
|
427
|
+
width: 'min(640px, calc(100vw - 16px))',
|
|
428
|
+
borderRadius: 16,
|
|
260
429
|
border: `1px solid ${tint(16)}`,
|
|
261
430
|
background: '#FFFFFF',
|
|
262
|
-
|
|
263
|
-
boxShadow: `0 1px 0 ${tint(8)}, 0
|
|
431
|
+
overflow: 'hidden',
|
|
432
|
+
boxShadow: `0 1px 0 ${tint(8)}, 0 24px 48px -20px rgba(58,40,12,0.35)`,
|
|
433
|
+
fontFamily: "'Inter', system-ui, sans-serif",
|
|
434
|
+
color: INK,
|
|
264
435
|
}}
|
|
265
436
|
>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}}
|
|
296
|
-
>
|
|
297
|
-
{o.name.slice(0, 1).toUpperCase()}
|
|
298
|
-
</div>
|
|
299
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
300
|
-
<div style={{ fontWeight: 500, fontSize: 13.5, color: '#2C1E14' }}>{o.name}</div>
|
|
301
|
-
<div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
|
|
302
|
-
{isCurrent
|
|
303
|
-
? `${ready.length} Service${ready.length === 1 ? '' : 's'} aktiv`
|
|
304
|
-
: o.slug}
|
|
305
|
-
</div>
|
|
306
|
-
</div>
|
|
307
|
-
{isCurrent ? (
|
|
308
|
-
<span
|
|
309
|
-
style={{
|
|
310
|
-
fontFamily: "'JetBrains Mono', monospace",
|
|
311
|
-
fontSize: 9.5,
|
|
312
|
-
color: BRAND_TEXT,
|
|
313
|
-
padding: '2px 6px',
|
|
314
|
-
background: tint(15),
|
|
315
|
-
borderRadius: 4,
|
|
316
|
-
textTransform: 'uppercase',
|
|
317
|
-
letterSpacing: '0.1em',
|
|
318
|
-
}}
|
|
319
|
-
>
|
|
320
|
-
aktuell
|
|
321
|
-
</span>
|
|
322
|
-
) : null}
|
|
323
|
-
</div>
|
|
324
|
-
)
|
|
325
|
-
})}
|
|
326
|
-
<hr
|
|
327
|
-
style={{
|
|
328
|
-
border: 'none',
|
|
329
|
-
borderTop: `1px solid ${tint(12)}`,
|
|
330
|
-
margin: '6px 0',
|
|
331
|
-
}}
|
|
332
|
-
/>
|
|
333
|
-
</>
|
|
334
|
-
) : null}
|
|
437
|
+
<label
|
|
438
|
+
style={{
|
|
439
|
+
display: 'flex',
|
|
440
|
+
alignItems: 'center',
|
|
441
|
+
gap: 10,
|
|
442
|
+
padding: '12px 16px',
|
|
443
|
+
borderBottom: `1px solid ${tint(12)}`,
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={MUTED} strokeWidth="2" strokeLinecap="round" aria-hidden>
|
|
447
|
+
<circle cx="11" cy="11" r="8" />
|
|
448
|
+
<path d="m21 21-4.3-4.3" />
|
|
449
|
+
</svg>
|
|
450
|
+
<input
|
|
451
|
+
ref={inputRef}
|
|
452
|
+
value={query}
|
|
453
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
454
|
+
onKeyDown={onInputKeyDown}
|
|
455
|
+
role="combobox"
|
|
456
|
+
aria-expanded={hasQuery}
|
|
457
|
+
aria-controls={listId}
|
|
458
|
+
aria-activedescendant={hasQuery && rowCount > 0 ? `${listId}-${active}` : undefined}
|
|
459
|
+
aria-label={isPlatformAdmin ? 'App oder Unternehmen suchen' : 'App suchen'}
|
|
460
|
+
placeholder={isPlatformAdmin ? 'App oder Unternehmen suchen …' : 'App suchen …'}
|
|
461
|
+
data-testid="brand-switcher-search"
|
|
462
|
+
style={{ flex: 1, minWidth: 0, border: 0, outline: 'none', background: 'transparent', font: 'inherit', fontSize: 15, color: INK }}
|
|
463
|
+
/>
|
|
464
|
+
<span style={KBD}>Esc</span>
|
|
465
|
+
</label>
|
|
335
466
|
|
|
336
|
-
<div style={
|
|
337
|
-
|
|
338
|
-
<a
|
|
339
|
-
href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
|
|
340
|
-
typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
|
|
341
|
-
)}`}
|
|
342
|
-
style={{ display: 'block', padding: '8px 12px 12px', fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
343
|
-
>
|
|
344
|
-
Anmelden, um Services zu sehen →
|
|
345
|
-
</a>
|
|
346
|
-
) : (
|
|
347
|
-
<>
|
|
348
|
-
{/* Base app is always a first-class entry — that's where users land
|
|
349
|
-
to book new services. */}
|
|
467
|
+
<div style={{ maxHeight: 'min(32rem, 70vh)', overflowY: 'auto', padding: 16, display: 'grid', gap: 16 }}>
|
|
468
|
+
{error && !shell ? (
|
|
350
469
|
<a
|
|
351
|
-
href={
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
alignItems: 'center',
|
|
356
|
-
gap: 12,
|
|
357
|
-
padding: '10px 12px',
|
|
358
|
-
borderRadius: 8,
|
|
359
|
-
textDecoration: 'none',
|
|
360
|
-
color: '#2C1E14',
|
|
361
|
-
}}
|
|
470
|
+
href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
|
|
471
|
+
typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
|
|
472
|
+
)}`}
|
|
473
|
+
style={{ fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
362
474
|
>
|
|
363
|
-
|
|
364
|
-
style={{
|
|
365
|
-
width: 32,
|
|
366
|
-
height: 32,
|
|
367
|
-
borderRadius: 8,
|
|
368
|
-
background: tint(10),
|
|
369
|
-
display: 'flex',
|
|
370
|
-
alignItems: 'center',
|
|
371
|
-
justifyContent: 'center',
|
|
372
|
-
}}
|
|
373
|
-
>
|
|
374
|
-
<StackIcon size={18} />
|
|
375
|
-
</div>
|
|
376
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
377
|
-
<div style={{ fontWeight: 500, fontSize: 13.5 }}>Base</div>
|
|
378
|
-
<div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
|
|
379
|
-
Katalog & Settings
|
|
380
|
-
</div>
|
|
381
|
-
</div>
|
|
382
|
-
<span
|
|
383
|
-
style={{
|
|
384
|
-
fontFamily: "'JetBrains Mono', monospace",
|
|
385
|
-
fontSize: 9.5,
|
|
386
|
-
color: '#7A6A58',
|
|
387
|
-
padding: '2px 6px',
|
|
388
|
-
background: tint(8),
|
|
389
|
-
borderRadius: 4,
|
|
390
|
-
textTransform: 'uppercase',
|
|
391
|
-
letterSpacing: '0.1em',
|
|
392
|
-
}}
|
|
393
|
-
>
|
|
394
|
-
Hub
|
|
395
|
-
</span>
|
|
475
|
+
Anmelden, um Services zu sehen →
|
|
396
476
|
</a>
|
|
477
|
+
) : hasQuery ? (
|
|
478
|
+
<div id={listId} role="listbox" aria-label="Treffer" style={{ display: 'grid', gap: 2 }}>
|
|
479
|
+
{hits.length > 0 ? <div style={{ ...LABEL, padding: '0 8px 4px' }}>Apps</div> : null}
|
|
480
|
+
{hits.map((app, i) =>
|
|
481
|
+
row(
|
|
482
|
+
app.key,
|
|
483
|
+
i,
|
|
484
|
+
app.href,
|
|
485
|
+
<span aria-hidden style={{ ...PLATE, width: 36, height: 36, borderRadius: 10 }}>
|
|
486
|
+
<AppGlyph app={app} size={17} />
|
|
487
|
+
</span>,
|
|
488
|
+
app.label,
|
|
489
|
+
app.tagline ?? '',
|
|
490
|
+
),
|
|
491
|
+
)}
|
|
492
|
+
{isPlatformAdmin ? (
|
|
493
|
+
<>
|
|
494
|
+
<div style={{ ...LABEL, padding: '10px 8px 4px', color: BRAND_TEXT }}>Plattform · Workspace wechseln</div>
|
|
495
|
+
{row(
|
|
496
|
+
'platform',
|
|
497
|
+
hits.length,
|
|
498
|
+
baseSearchUrl(baseUrl, query),
|
|
499
|
+
<span aria-hidden style={{ ...PLATE, width: 36, height: 36, borderRadius: 10 }}>
|
|
500
|
+
<StackIcon size={17} />
|
|
501
|
+
</span>,
|
|
502
|
+
`„${query.trim()}“ unter Unternehmen suchen`,
|
|
503
|
+
'Öffnet Base, der Wechsel läuft dort',
|
|
504
|
+
)}
|
|
505
|
+
</>
|
|
506
|
+
) : hits.length === 0 ? (
|
|
507
|
+
<div style={{ padding: 8, fontSize: 13, color: MUTED }}>Nichts gefunden. Versuch es mit einem App-Namen.</div>
|
|
508
|
+
) : null}
|
|
509
|
+
</div>
|
|
510
|
+
) : (
|
|
511
|
+
<>
|
|
512
|
+
<section aria-label="Apps" style={{ display: 'grid', gap: 8 }}>
|
|
513
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, padding: '0 4px' }}>
|
|
514
|
+
<span style={LABEL}>Deine Apps</span>
|
|
515
|
+
{shell?.catalogUrl ? (
|
|
516
|
+
<a href={shell.catalogUrl} style={{ fontSize: 12.5, fontWeight: 500, color: BRAND_TEXT, textDecoration: 'none' }}>
|
|
517
|
+
Katalog öffnen
|
|
518
|
+
</a>
|
|
519
|
+
) : null}
|
|
520
|
+
</div>
|
|
521
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(7rem, 1fr))', gap: 4 }}>
|
|
522
|
+
{apps.map(tile)}
|
|
523
|
+
</div>
|
|
524
|
+
</section>
|
|
525
|
+
|
|
526
|
+
{orgs.length > 1 ? (
|
|
527
|
+
<section aria-label="Workspaces" style={{ display: 'grid', gap: 8, borderTop: `1px solid ${tint(12)}`, paddingTop: 12 }}>
|
|
528
|
+
<span style={{ ...LABEL, padding: '0 4px' }}>Deine Workspaces</span>
|
|
529
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
530
|
+
{orgs.map((o) => {
|
|
531
|
+
const isCurrent = o.id === shell?.org?.id
|
|
532
|
+
return (
|
|
533
|
+
<span
|
|
534
|
+
key={o.id}
|
|
535
|
+
style={{
|
|
536
|
+
display: 'inline-flex',
|
|
537
|
+
alignItems: 'center',
|
|
538
|
+
gap: 6,
|
|
539
|
+
padding: '3px 12px 3px 4px',
|
|
540
|
+
borderRadius: 99,
|
|
541
|
+
fontSize: 12.5,
|
|
542
|
+
border: `1px solid ${isCurrent ? BRAND : 'rgba(44,30,20,0.14)'}`,
|
|
543
|
+
background: isCurrent ? tint(15) : 'rgba(44,30,20,0.03)',
|
|
544
|
+
color: isCurrent ? BRAND_TEXT : INK,
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
<span aria-hidden style={{ width: 20, height: 20, borderRadius: 99, display: 'grid', placeItems: 'center', background: '#fff', border: '1px solid rgba(44,30,20,0.14)', fontFamily: MONO, fontSize: 9, color: MUTED }}>
|
|
548
|
+
{o.name.slice(0, 2).toUpperCase()}
|
|
549
|
+
</span>
|
|
550
|
+
{o.name}
|
|
551
|
+
</span>
|
|
552
|
+
)
|
|
553
|
+
})}
|
|
554
|
+
</div>
|
|
555
|
+
</section>
|
|
556
|
+
) : null}
|
|
397
557
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
ready.map((b) => {
|
|
404
|
-
const active = b.service === config.appKey
|
|
405
|
-
return (
|
|
558
|
+
{isPlatformAdmin ? (
|
|
559
|
+
<section aria-label="Plattform" style={{ display: 'grid', gap: 6, borderTop: `1px solid ${tint(12)}`, paddingTop: 12 }}>
|
|
560
|
+
<span style={{ ...LABEL, padding: '0 4px' }}>
|
|
561
|
+
Plattform · Workspace <span style={{ textTransform: 'none', letterSpacing: 0 }}>nur Super-Admins</span>
|
|
562
|
+
</span>
|
|
406
563
|
<a
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
style={{
|
|
411
|
-
display: 'flex',
|
|
412
|
-
alignItems: 'center',
|
|
413
|
-
gap: 12,
|
|
414
|
-
padding: '10px 12px',
|
|
415
|
-
borderRadius: 8,
|
|
416
|
-
textDecoration: 'none',
|
|
417
|
-
color: '#2C1E14',
|
|
418
|
-
background: active ? tint(15) : 'transparent',
|
|
419
|
-
}}
|
|
564
|
+
href={baseSearchUrl(baseUrl, '')}
|
|
565
|
+
data-testid="brand-switcher-platform"
|
|
566
|
+
style={{ padding: '0 4px', fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
420
567
|
>
|
|
421
|
-
|
|
422
|
-
style={{
|
|
423
|
-
width: 32,
|
|
424
|
-
height: 32,
|
|
425
|
-
borderRadius: 8,
|
|
426
|
-
background: active ? tint(25) : 'rgba(176,138,122,0.25)',
|
|
427
|
-
color: active ? BRAND_TEXT : '#B08A7A',
|
|
428
|
-
display: 'flex',
|
|
429
|
-
alignItems: 'center',
|
|
430
|
-
justifyContent: 'center',
|
|
431
|
-
fontFamily: "'Red Hat Display', system-ui, sans-serif",
|
|
432
|
-
fontWeight: 600,
|
|
433
|
-
}}
|
|
434
|
-
>
|
|
435
|
-
<ServiceIcon name={b.icon} label={b.label} />
|
|
436
|
-
</div>
|
|
437
|
-
<div style={{ flex: 1, minWidth: 0 }}>
|
|
438
|
-
<div style={{ fontWeight: 500, fontSize: 13.5 }}>{b.label}</div>
|
|
439
|
-
<div style={{ fontSize: 11.5, color: '#7A6A58', marginTop: 2 }}>
|
|
440
|
-
{active ? 'aktuell geöffnet' : 'bereit'}
|
|
441
|
-
</div>
|
|
442
|
-
</div>
|
|
443
|
-
{active ? (
|
|
444
|
-
<span
|
|
445
|
-
style={{
|
|
446
|
-
fontFamily: "'JetBrains Mono', monospace",
|
|
447
|
-
fontSize: 9.5,
|
|
448
|
-
color: BRAND_TEXT,
|
|
449
|
-
padding: '2px 6px',
|
|
450
|
-
background: tint(15),
|
|
451
|
-
borderRadius: 4,
|
|
452
|
-
textTransform: 'uppercase',
|
|
453
|
-
letterSpacing: '0.1em',
|
|
454
|
-
}}
|
|
455
|
-
>
|
|
456
|
-
aktuell
|
|
457
|
-
</span>
|
|
458
|
-
) : (
|
|
459
|
-
<span
|
|
460
|
-
style={{
|
|
461
|
-
fontFamily: "'JetBrains Mono', monospace",
|
|
462
|
-
fontSize: 9.5,
|
|
463
|
-
color: '#3F8F5C',
|
|
464
|
-
padding: '2px 6px',
|
|
465
|
-
background: 'rgba(63,143,92,0.12)',
|
|
466
|
-
borderRadius: 4,
|
|
467
|
-
textTransform: 'uppercase',
|
|
468
|
-
letterSpacing: '0.1em',
|
|
469
|
-
}}
|
|
470
|
-
>
|
|
471
|
-
bereit
|
|
472
|
-
</span>
|
|
473
|
-
)}
|
|
568
|
+
Unternehmen in Base wechseln →
|
|
474
569
|
</a>
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
<
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
</a>
|
|
507
|
-
</>
|
|
508
|
-
)}
|
|
570
|
+
</section>
|
|
571
|
+
) : null}
|
|
572
|
+
</>
|
|
573
|
+
)}
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
{/* Logout clears both the local OIDC cookie and the upstream SSO session
|
|
577
|
+
(via <provider>/api/sso/signout), so it stays reachable here. */}
|
|
578
|
+
<div
|
|
579
|
+
style={{
|
|
580
|
+
display: 'flex',
|
|
581
|
+
flexWrap: 'wrap',
|
|
582
|
+
alignItems: 'center',
|
|
583
|
+
gap: '4px 16px',
|
|
584
|
+
padding: '8px 16px',
|
|
585
|
+
borderTop: `1px solid ${tint(12)}`,
|
|
586
|
+
fontSize: 11.5,
|
|
587
|
+
color: MUTED,
|
|
588
|
+
}}
|
|
589
|
+
>
|
|
590
|
+
<span>↑↓ auswählen</span>
|
|
591
|
+
<span>↵ öffnen</span>
|
|
592
|
+
<span>{shortcut} öffnet und schließt</span>
|
|
593
|
+
<a
|
|
594
|
+
href="/api/oidc/signout"
|
|
595
|
+
data-testid="brand-switcher-logout"
|
|
596
|
+
style={{ marginLeft: 'auto', color: '#B0413A', fontWeight: 500, fontSize: 13, textDecoration: 'none' }}
|
|
597
|
+
>
|
|
598
|
+
Abmelden
|
|
599
|
+
</a>
|
|
600
|
+
</div>
|
|
509
601
|
</div>
|
|
510
602
|
) : null
|
|
511
603
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search for the app palette in BrandSwitcher (⌘K / Ctrl+K).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors rflgd-base's `src/lib/shell/palette.ts`, minus tenants: a module
|
|
5
|
+
* cannot start a platform tenant switch (that runs on Base's session), so
|
|
6
|
+
* platform admins jump to the Base palette instead (`baseSearchUrl`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PaletteApp = {
|
|
10
|
+
key: string
|
|
11
|
+
label: string
|
|
12
|
+
/** One line from the catalog that says what the app does. */
|
|
13
|
+
tagline: string | null
|
|
14
|
+
/** Lucide name from the platform; `null` for Base, which shows the stack. */
|
|
15
|
+
icon: string | null
|
|
16
|
+
href: string
|
|
17
|
+
current: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function matchApps(apps: PaletteApp[], query: string): PaletteApp[] {
|
|
21
|
+
const q = query.trim().toLowerCase()
|
|
22
|
+
if (!q) return apps
|
|
23
|
+
return apps.filter((a) => a.label.toLowerCase().includes(q) || a.tagline?.toLowerCase().includes(q))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Base opens its palette with `?palette=<query>` prefilled. */
|
|
27
|
+
export function baseSearchUrl(baseUrl: string, query: string): string {
|
|
28
|
+
return `${baseUrl.replace(/\/+$/, '')}/?palette=${encodeURIComponent(query.trim())}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** ⌘K on macOS, Ctrl+K elsewhere; Alt/Shift combinations stay free. */
|
|
32
|
+
export function isPaletteShortcut(
|
|
33
|
+
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>,
|
|
34
|
+
): boolean {
|
|
35
|
+
return (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k'
|
|
36
|
+
}
|