@reflagged/shell 1.5.1 → 1.6.0
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 +387 -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,110 @@ 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
|
+
e.preventDefault()
|
|
190
|
+
place()
|
|
191
|
+
setOpen((v) => !v)
|
|
136
192
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
window.removeEventListener('resize', update)
|
|
141
|
-
window.removeEventListener('scroll', update, true)
|
|
142
|
-
}
|
|
143
|
-
}, [open])
|
|
193
|
+
document.addEventListener('keydown', onKeyDown)
|
|
194
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
195
|
+
}, [])
|
|
144
196
|
|
|
197
|
+
// While open: follow the trigger, close on outside click, focus the search.
|
|
145
198
|
useEffect(() => {
|
|
146
|
-
if (!open)
|
|
199
|
+
if (!open) {
|
|
200
|
+
setQuery('')
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
requestAnimationFrame(() => inputRef.current?.focus())
|
|
147
204
|
const onClick = (e: MouseEvent) => {
|
|
148
205
|
if ((e.target as HTMLElement)?.closest('[data-brand-switcher]')) return
|
|
149
206
|
setOpen(false)
|
|
150
207
|
}
|
|
208
|
+
window.addEventListener('resize', place)
|
|
209
|
+
window.addEventListener('scroll', place, true)
|
|
151
210
|
document.addEventListener('mousedown', onClick)
|
|
152
|
-
return () =>
|
|
211
|
+
return () => {
|
|
212
|
+
window.removeEventListener('resize', place)
|
|
213
|
+
window.removeEventListener('scroll', place, true)
|
|
214
|
+
document.removeEventListener('mousedown', onClick)
|
|
215
|
+
}
|
|
153
216
|
}, [open])
|
|
154
217
|
|
|
155
218
|
useTenantTheme(shell?.org?.themeCssUrl)
|
|
156
219
|
|
|
157
|
-
const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
|
|
158
220
|
const orgName = shell?.org?.name ?? null
|
|
159
221
|
const baseUrl = shell?.baseUrl ?? 'https://app.rfl.gd'
|
|
160
222
|
const orgs = shell?.orgs ?? (shell?.org ? [shell.org] : [])
|
|
161
|
-
const
|
|
223
|
+
const isPlatformAdmin = shell?.user.role === 'superadmin' || shell?.user.role === 'reflagged_admin'
|
|
224
|
+
|
|
225
|
+
const apps = useMemo<PaletteApp[]>(() => {
|
|
226
|
+
const taglines = new Map((shell?.catalog ?? []).map((c) => [c.service, c.tagline]))
|
|
227
|
+
const ready = (shell?.bookings ?? []).filter((b) => b.status === 'ready' && b.url)
|
|
228
|
+
return [
|
|
229
|
+
{ key: 'base', label: 'Base', tagline: 'Start, Team und Einstellungen', icon: null, href: baseUrl, current: false },
|
|
230
|
+
...ready.map((b) => ({
|
|
231
|
+
key: b.service,
|
|
232
|
+
label: b.label,
|
|
233
|
+
tagline: taglines.get(b.service) ?? null,
|
|
234
|
+
icon: b.icon ?? '',
|
|
235
|
+
href: b.url as string,
|
|
236
|
+
current: b.service === config.appKey,
|
|
237
|
+
})),
|
|
238
|
+
]
|
|
239
|
+
}, [shell, baseUrl, config.appKey])
|
|
240
|
+
|
|
241
|
+
const hits = useMemo(() => matchApps(apps, query), [apps, query])
|
|
242
|
+
const hasQuery = query.trim().length > 0
|
|
243
|
+
// The platform jump is the last row while searching, so ↓ reaches it.
|
|
244
|
+
const rowCount = hits.length + (hasQuery && isPlatformAdmin ? 1 : 0)
|
|
245
|
+
|
|
246
|
+
useEffect(() => setActive(0), [query])
|
|
247
|
+
|
|
248
|
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
249
|
+
if (e.key === 'ArrowDown') {
|
|
250
|
+
e.preventDefault()
|
|
251
|
+
setActive((i) => Math.min(i + 1, rowCount - 1))
|
|
252
|
+
} else if (e.key === 'ArrowUp') {
|
|
253
|
+
e.preventDefault()
|
|
254
|
+
setActive((i) => Math.max(i - 1, 0))
|
|
255
|
+
} else if (e.key === 'Enter' && hasQuery) {
|
|
256
|
+
e.preventDefault()
|
|
257
|
+
const hit = hits[active]
|
|
258
|
+
if (hit) window.location.assign(hit.href)
|
|
259
|
+
else if (isPlatformAdmin) window.location.assign(baseSearchUrl(baseUrl, query))
|
|
260
|
+
} else if (e.key === 'Escape') {
|
|
261
|
+
e.preventDefault()
|
|
262
|
+
if (query) setQuery('')
|
|
263
|
+
else {
|
|
264
|
+
setOpen(false)
|
|
265
|
+
buttonRef.current?.focus()
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
162
269
|
|
|
163
270
|
const trigger = (
|
|
164
271
|
<div ref={triggerRef}>
|
|
165
272
|
<button
|
|
273
|
+
ref={buttonRef}
|
|
166
274
|
type="button"
|
|
167
|
-
onClick={
|
|
168
|
-
aria-haspopup="
|
|
275
|
+
onClick={toggle}
|
|
276
|
+
aria-haspopup="dialog"
|
|
169
277
|
aria-expanded={open}
|
|
170
|
-
aria-label="
|
|
278
|
+
aria-label="Apps wechseln"
|
|
171
279
|
style={{
|
|
172
280
|
display: 'flex',
|
|
173
281
|
alignItems: 'center',
|
|
@@ -182,7 +290,7 @@ export function BrandSwitcher() {
|
|
|
182
290
|
}}
|
|
183
291
|
>
|
|
184
292
|
<BrandMark org={shell?.org} size={22} />
|
|
185
|
-
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.15 }}>
|
|
293
|
+
<div style={{ flex: 1, minWidth: 0, lineHeight: 1.15, textAlign: 'left' }}>
|
|
186
294
|
<div
|
|
187
295
|
style={{
|
|
188
296
|
fontFamily: "'Red Hat Display', system-ui, sans-serif",
|
|
@@ -192,17 +300,17 @@ export function BrandSwitcher() {
|
|
|
192
300
|
whiteSpace: 'nowrap',
|
|
193
301
|
overflow: 'hidden',
|
|
194
302
|
textOverflow: 'ellipsis',
|
|
195
|
-
color:
|
|
303
|
+
color: INK,
|
|
196
304
|
}}
|
|
197
305
|
>
|
|
198
306
|
{config.appLabel}
|
|
199
307
|
</div>
|
|
200
308
|
<div
|
|
201
309
|
style={{
|
|
202
|
-
fontFamily:
|
|
310
|
+
fontFamily: MONO,
|
|
203
311
|
fontSize: 10.5,
|
|
204
312
|
letterSpacing: '0.1em',
|
|
205
|
-
color:
|
|
313
|
+
color: MUTED,
|
|
206
314
|
marginTop: 2,
|
|
207
315
|
whiteSpace: 'nowrap',
|
|
208
316
|
overflow: 'hidden',
|
|
@@ -212,300 +320,281 @@ export function BrandSwitcher() {
|
|
|
212
320
|
{orgName ? `${orgName} · rfl.gd` : 'rfl.gd'}
|
|
213
321
|
</div>
|
|
214
322
|
</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>
|
|
323
|
+
<span style={{ ...KBD, marginLeft: 4 }}>{shortcut}</span>
|
|
235
324
|
</button>
|
|
236
325
|
</div>
|
|
237
326
|
)
|
|
238
327
|
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
328
|
+
const tile = (app: PaletteApp) => (
|
|
329
|
+
<a
|
|
330
|
+
key={app.key}
|
|
331
|
+
href={app.href}
|
|
332
|
+
aria-current={app.current ? 'page' : undefined}
|
|
333
|
+
data-testid={`brand-switcher-app-${app.key}`}
|
|
334
|
+
style={{
|
|
335
|
+
position: 'relative',
|
|
336
|
+
display: 'flex',
|
|
337
|
+
flexDirection: 'column',
|
|
338
|
+
alignItems: 'center',
|
|
339
|
+
gap: 6,
|
|
340
|
+
padding: '12px 8px',
|
|
341
|
+
borderRadius: 12,
|
|
342
|
+
textDecoration: 'none',
|
|
343
|
+
textAlign: 'center',
|
|
344
|
+
color: INK,
|
|
345
|
+
background: app.current ? tint(15) : 'transparent',
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
{app.current ? (
|
|
349
|
+
<span style={{ ...LABEL, position: 'absolute', top: 8, right: 8, fontSize: 9, color: BRAND_TEXT }}>Hier</span>
|
|
350
|
+
) : null}
|
|
351
|
+
<span aria-hidden style={{ ...PLATE, width: 48, height: 48, borderRadius: 14 }}>
|
|
352
|
+
<AppGlyph app={app} size={22} />
|
|
353
|
+
</span>
|
|
354
|
+
<span style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.2 }}>{app.label}</span>
|
|
355
|
+
{app.tagline ? (
|
|
356
|
+
<span
|
|
357
|
+
style={{
|
|
358
|
+
fontSize: 11.5,
|
|
359
|
+
lineHeight: 1.3,
|
|
360
|
+
color: MUTED,
|
|
361
|
+
display: '-webkit-box',
|
|
362
|
+
WebkitLineClamp: 2,
|
|
363
|
+
WebkitBoxOrient: 'vertical',
|
|
364
|
+
overflow: 'hidden',
|
|
365
|
+
}}
|
|
366
|
+
>
|
|
367
|
+
{app.tagline}
|
|
368
|
+
</span>
|
|
369
|
+
) : null}
|
|
370
|
+
</a>
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
const row = (key: string, index: number, href: string, icon: React.ReactNode, title: string, sub: string) => (
|
|
374
|
+
<a
|
|
375
|
+
key={key}
|
|
376
|
+
id={`${listId}-${index}`}
|
|
377
|
+
role="option"
|
|
378
|
+
aria-selected={index === active}
|
|
379
|
+
href={href}
|
|
380
|
+
onMouseEnter={() => setActive(index)}
|
|
381
|
+
style={{
|
|
382
|
+
display: 'flex',
|
|
383
|
+
alignItems: 'center',
|
|
384
|
+
gap: 12,
|
|
385
|
+
padding: 8,
|
|
386
|
+
borderRadius: 10,
|
|
387
|
+
textDecoration: 'none',
|
|
388
|
+
color: INK,
|
|
389
|
+
background: index === active ? 'rgba(44,30,20,0.05)' : 'transparent',
|
|
390
|
+
}}
|
|
391
|
+
>
|
|
392
|
+
{icon}
|
|
393
|
+
<span style={{ flex: 1, minWidth: 0 }}>
|
|
394
|
+
<span style={{ display: 'block', fontSize: 13.5, fontWeight: 600 }}>{title}</span>
|
|
395
|
+
<span
|
|
396
|
+
style={{
|
|
397
|
+
display: 'block',
|
|
398
|
+
fontSize: 12,
|
|
399
|
+
color: MUTED,
|
|
400
|
+
whiteSpace: 'nowrap',
|
|
401
|
+
overflow: 'hidden',
|
|
402
|
+
textOverflow: 'ellipsis',
|
|
403
|
+
}}
|
|
404
|
+
>
|
|
405
|
+
{sub}
|
|
406
|
+
</span>
|
|
407
|
+
</span>
|
|
408
|
+
{index === active ? <span style={{ ...KBD, border: 'none' }}>↵</span> : null}
|
|
409
|
+
</a>
|
|
410
|
+
)
|
|
247
411
|
|
|
248
412
|
const popover = open ? (
|
|
249
413
|
<div
|
|
250
|
-
role="
|
|
414
|
+
role="dialog"
|
|
415
|
+
aria-label="Apps und Workspaces"
|
|
251
416
|
data-brand-switcher
|
|
417
|
+
data-testid="brand-switcher-panel"
|
|
252
418
|
style={{
|
|
253
419
|
position: pos ? 'fixed' : 'absolute',
|
|
254
420
|
left: pos ? pos.left : 0,
|
|
255
421
|
top: pos ? pos.top : '100%',
|
|
256
422
|
zIndex: 2147483647,
|
|
257
423
|
marginTop: pos ? 0 : 8,
|
|
258
|
-
width:
|
|
259
|
-
borderRadius:
|
|
424
|
+
width: 'min(640px, calc(100vw - 16px))',
|
|
425
|
+
borderRadius: 16,
|
|
260
426
|
border: `1px solid ${tint(16)}`,
|
|
261
427
|
background: '#FFFFFF',
|
|
262
|
-
|
|
263
|
-
boxShadow: `0 1px 0 ${tint(8)}, 0
|
|
428
|
+
overflow: 'hidden',
|
|
429
|
+
boxShadow: `0 1px 0 ${tint(8)}, 0 24px 48px -20px rgba(58,40,12,0.35)`,
|
|
430
|
+
fontFamily: "'Inter', system-ui, sans-serif",
|
|
431
|
+
color: INK,
|
|
264
432
|
}}
|
|
265
433
|
>
|
|
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}
|
|
434
|
+
<label
|
|
435
|
+
style={{
|
|
436
|
+
display: 'flex',
|
|
437
|
+
alignItems: 'center',
|
|
438
|
+
gap: 10,
|
|
439
|
+
padding: '12px 16px',
|
|
440
|
+
borderBottom: `1px solid ${tint(12)}`,
|
|
441
|
+
}}
|
|
442
|
+
>
|
|
443
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={MUTED} strokeWidth="2" strokeLinecap="round" aria-hidden>
|
|
444
|
+
<circle cx="11" cy="11" r="8" />
|
|
445
|
+
<path d="m21 21-4.3-4.3" />
|
|
446
|
+
</svg>
|
|
447
|
+
<input
|
|
448
|
+
ref={inputRef}
|
|
449
|
+
value={query}
|
|
450
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
451
|
+
onKeyDown={onInputKeyDown}
|
|
452
|
+
role="combobox"
|
|
453
|
+
aria-expanded={hasQuery}
|
|
454
|
+
aria-controls={listId}
|
|
455
|
+
aria-activedescendant={hasQuery && rowCount > 0 ? `${listId}-${active}` : undefined}
|
|
456
|
+
aria-label={isPlatformAdmin ? 'App oder Unternehmen suchen' : 'App suchen'}
|
|
457
|
+
placeholder={isPlatformAdmin ? 'App oder Unternehmen suchen …' : 'App suchen …'}
|
|
458
|
+
data-testid="brand-switcher-search"
|
|
459
|
+
style={{ flex: 1, minWidth: 0, border: 0, outline: 'none', background: 'transparent', font: 'inherit', fontSize: 15, color: INK }}
|
|
460
|
+
/>
|
|
461
|
+
<span style={KBD}>Esc</span>
|
|
462
|
+
</label>
|
|
335
463
|
|
|
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. */}
|
|
464
|
+
<div style={{ maxHeight: 'min(32rem, 70vh)', overflowY: 'auto', padding: 16, display: 'grid', gap: 16 }}>
|
|
465
|
+
{error && !shell ? (
|
|
350
466
|
<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
|
-
}}
|
|
467
|
+
href={`/api/oidc/signin?callbackUrl=${encodeURIComponent(
|
|
468
|
+
typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/',
|
|
469
|
+
)}`}
|
|
470
|
+
style={{ fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
362
471
|
>
|
|
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>
|
|
472
|
+
Anmelden, um Services zu sehen →
|
|
396
473
|
</a>
|
|
474
|
+
) : hasQuery ? (
|
|
475
|
+
<div id={listId} role="listbox" aria-label="Treffer" style={{ display: 'grid', gap: 2 }}>
|
|
476
|
+
{hits.length > 0 ? <div style={{ ...LABEL, padding: '0 8px 4px' }}>Apps</div> : null}
|
|
477
|
+
{hits.map((app, i) =>
|
|
478
|
+
row(
|
|
479
|
+
app.key,
|
|
480
|
+
i,
|
|
481
|
+
app.href,
|
|
482
|
+
<span aria-hidden style={{ ...PLATE, width: 36, height: 36, borderRadius: 10 }}>
|
|
483
|
+
<AppGlyph app={app} size={17} />
|
|
484
|
+
</span>,
|
|
485
|
+
app.label,
|
|
486
|
+
app.tagline ?? '',
|
|
487
|
+
),
|
|
488
|
+
)}
|
|
489
|
+
{isPlatformAdmin ? (
|
|
490
|
+
<>
|
|
491
|
+
<div style={{ ...LABEL, padding: '10px 8px 4px', color: BRAND_TEXT }}>Plattform · Workspace wechseln</div>
|
|
492
|
+
{row(
|
|
493
|
+
'platform',
|
|
494
|
+
hits.length,
|
|
495
|
+
baseSearchUrl(baseUrl, query),
|
|
496
|
+
<span aria-hidden style={{ ...PLATE, width: 36, height: 36, borderRadius: 10 }}>
|
|
497
|
+
<StackIcon size={17} />
|
|
498
|
+
</span>,
|
|
499
|
+
`„${query.trim()}“ unter Unternehmen suchen`,
|
|
500
|
+
'Öffnet Base, der Wechsel läuft dort',
|
|
501
|
+
)}
|
|
502
|
+
</>
|
|
503
|
+
) : hits.length === 0 ? (
|
|
504
|
+
<div style={{ padding: 8, fontSize: 13, color: MUTED }}>Nichts gefunden. Versuch es mit einem App-Namen.</div>
|
|
505
|
+
) : null}
|
|
506
|
+
</div>
|
|
507
|
+
) : (
|
|
508
|
+
<>
|
|
509
|
+
<section aria-label="Apps" style={{ display: 'grid', gap: 8 }}>
|
|
510
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, padding: '0 4px' }}>
|
|
511
|
+
<span style={LABEL}>Deine Apps</span>
|
|
512
|
+
{shell?.catalogUrl ? (
|
|
513
|
+
<a href={shell.catalogUrl} style={{ fontSize: 12.5, fontWeight: 500, color: BRAND_TEXT, textDecoration: 'none' }}>
|
|
514
|
+
Katalog öffnen
|
|
515
|
+
</a>
|
|
516
|
+
) : null}
|
|
517
|
+
</div>
|
|
518
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(7rem, 1fr))', gap: 4 }}>
|
|
519
|
+
{apps.map(tile)}
|
|
520
|
+
</div>
|
|
521
|
+
</section>
|
|
522
|
+
|
|
523
|
+
{orgs.length > 1 ? (
|
|
524
|
+
<section aria-label="Workspaces" style={{ display: 'grid', gap: 8, borderTop: `1px solid ${tint(12)}`, paddingTop: 12 }}>
|
|
525
|
+
<span style={{ ...LABEL, padding: '0 4px' }}>Deine Workspaces</span>
|
|
526
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
527
|
+
{orgs.map((o) => {
|
|
528
|
+
const isCurrent = o.id === shell?.org?.id
|
|
529
|
+
return (
|
|
530
|
+
<span
|
|
531
|
+
key={o.id}
|
|
532
|
+
style={{
|
|
533
|
+
display: 'inline-flex',
|
|
534
|
+
alignItems: 'center',
|
|
535
|
+
gap: 6,
|
|
536
|
+
padding: '3px 12px 3px 4px',
|
|
537
|
+
borderRadius: 99,
|
|
538
|
+
fontSize: 12.5,
|
|
539
|
+
border: `1px solid ${isCurrent ? BRAND : 'rgba(44,30,20,0.14)'}`,
|
|
540
|
+
background: isCurrent ? tint(15) : 'rgba(44,30,20,0.03)',
|
|
541
|
+
color: isCurrent ? BRAND_TEXT : INK,
|
|
542
|
+
}}
|
|
543
|
+
>
|
|
544
|
+
<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 }}>
|
|
545
|
+
{o.name.slice(0, 2).toUpperCase()}
|
|
546
|
+
</span>
|
|
547
|
+
{o.name}
|
|
548
|
+
</span>
|
|
549
|
+
)
|
|
550
|
+
})}
|
|
551
|
+
</div>
|
|
552
|
+
</section>
|
|
553
|
+
) : null}
|
|
397
554
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
ready.map((b) => {
|
|
404
|
-
const active = b.service === config.appKey
|
|
405
|
-
return (
|
|
555
|
+
{isPlatformAdmin ? (
|
|
556
|
+
<section aria-label="Plattform" style={{ display: 'grid', gap: 6, borderTop: `1px solid ${tint(12)}`, paddingTop: 12 }}>
|
|
557
|
+
<span style={{ ...LABEL, padding: '0 4px' }}>
|
|
558
|
+
Plattform · Workspace <span style={{ textTransform: 'none', letterSpacing: 0 }}>nur Super-Admins</span>
|
|
559
|
+
</span>
|
|
406
560
|
<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
|
-
}}
|
|
561
|
+
href={baseSearchUrl(baseUrl, '')}
|
|
562
|
+
data-testid="brand-switcher-platform"
|
|
563
|
+
style={{ padding: '0 4px', fontSize: 13, color: BRAND_TEXT, textDecoration: 'none' }}
|
|
420
564
|
>
|
|
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
|
-
)}
|
|
565
|
+
Unternehmen in Base wechseln →
|
|
474
566
|
</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
|
-
)}
|
|
567
|
+
</section>
|
|
568
|
+
) : null}
|
|
569
|
+
</>
|
|
570
|
+
)}
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
{/* Logout clears both the local OIDC cookie and the upstream SSO session
|
|
574
|
+
(via <provider>/api/sso/signout), so it stays reachable here. */}
|
|
575
|
+
<div
|
|
576
|
+
style={{
|
|
577
|
+
display: 'flex',
|
|
578
|
+
flexWrap: 'wrap',
|
|
579
|
+
alignItems: 'center',
|
|
580
|
+
gap: '4px 16px',
|
|
581
|
+
padding: '8px 16px',
|
|
582
|
+
borderTop: `1px solid ${tint(12)}`,
|
|
583
|
+
fontSize: 11.5,
|
|
584
|
+
color: MUTED,
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
<span>↑↓ auswählen</span>
|
|
588
|
+
<span>↵ öffnen</span>
|
|
589
|
+
<span>{shortcut} öffnet und schließt</span>
|
|
590
|
+
<a
|
|
591
|
+
href="/api/oidc/signout"
|
|
592
|
+
data-testid="brand-switcher-logout"
|
|
593
|
+
style={{ marginLeft: 'auto', color: '#B0413A', fontWeight: 500, fontSize: 13, textDecoration: 'none' }}
|
|
594
|
+
>
|
|
595
|
+
Abmelden
|
|
596
|
+
</a>
|
|
597
|
+
</div>
|
|
509
598
|
</div>
|
|
510
599
|
) : null
|
|
511
600
|
|
|
@@ -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
|
+
}
|