@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 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 switcher dropdown)
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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 handleToggle = () => {
123
- if (!open && triggerRef.current) {
124
- const r = triggerRef.current.getBoundingClientRect()
125
- setPos({ top: r.bottom + 4, left: r.left })
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
- // Keep position in sync on resize/scroll while open
185
+ // Global shortcut.
131
186
  useEffect(() => {
132
- if (!open || !triggerRef.current) return
133
- const update = () => {
134
- const r = triggerRef.current?.getBoundingClientRect()
135
- if (r) setPos({ top: r.bottom + 4, left: r.left })
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
- window.addEventListener('resize', update)
138
- window.addEventListener('scroll', update, true)
139
- return () => {
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) return
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 () => document.removeEventListener('mousedown', onClick)
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 showWorkspaceSection = orgs.length > 1
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={handleToggle}
168
- aria-haspopup="menu"
278
+ onClick={toggle}
279
+ aria-haspopup="dialog"
169
280
  aria-expanded={open}
170
- aria-label="Reflagged Apps wechseln"
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: '#2C1E14',
306
+ color: INK,
196
307
  }}
197
308
  >
198
309
  {config.appLabel}
199
310
  </div>
200
311
  <div
201
312
  style={{
202
- fontFamily: "'JetBrains Mono', monospace",
313
+ fontFamily: MONO,
203
314
  fontSize: 10.5,
204
315
  letterSpacing: '0.1em',
205
- color: '#7A6A58',
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
- {orgName ? (
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 sectionLabelStyle: React.CSSProperties = {
240
- padding: '10px 12px 6px',
241
- fontFamily: "'JetBrains Mono', monospace",
242
- fontSize: 10,
243
- color: '#7A6A58',
244
- textTransform: 'uppercase',
245
- letterSpacing: '0.12em',
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="menu"
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: 280,
259
- borderRadius: 14,
427
+ width: 'min(640px, calc(100vw - 16px))',
428
+ borderRadius: 16,
260
429
  border: `1px solid ${tint(16)}`,
261
430
  background: '#FFFFFF',
262
- padding: 8,
263
- boxShadow: `0 1px 0 ${tint(8)}, 0 12px 40px rgba(0,0,0,0.15)`,
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
- {showWorkspaceSection ? (
267
- <>
268
- <div style={sectionLabelStyle}>Workspace</div>
269
- {orgs.map((o) => {
270
- const isCurrent = o.id === shell?.org?.id
271
- return (
272
- <div
273
- key={o.id}
274
- style={{
275
- display: 'flex',
276
- alignItems: 'center',
277
- gap: 12,
278
- padding: '8px 12px',
279
- borderRadius: 8,
280
- background: isCurrent ? tint(15) : 'transparent',
281
- }}
282
- >
283
- <div
284
- style={{
285
- width: 32,
286
- height: 32,
287
- borderRadius: 8,
288
- background: tint(15),
289
- color: BRAND_TEXT,
290
- display: 'flex',
291
- alignItems: 'center',
292
- justifyContent: 'center',
293
- fontFamily: "'Red Hat Display', system-ui, sans-serif",
294
- fontWeight: 600,
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={sectionLabelStyle}>Apps</div>
337
- {error && !shell ? (
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={baseUrl}
352
- role="menuitem"
353
- style={{
354
- display: 'flex',
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
- <div
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
- {ready.length === 0 ? (
399
- <div style={{ padding: '8px 12px 12px', fontSize: 13, color: '#7A6A58' }}>
400
- Noch keine aktiven Services.
401
- </div>
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
- key={b.id}
408
- href={b.url ?? '#'}
409
- role="menuitem"
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
- <div
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
- {/* Single-logout — clears this service's session AND the platform
480
- SSO session (via <provider>/api/sso/signout). Present for every
481
- consumer so logout is always reachable from the app-switcher. */}
482
- <hr
483
- style={{
484
- border: 'none',
485
- borderTop: `1px solid ${tint(12)}`,
486
- margin: '6px 0',
487
- }}
488
- />
489
- <a
490
- href="/api/oidc/signout"
491
- role="menuitem"
492
- data-testid="brand-switcher-logout"
493
- style={{
494
- display: 'flex',
495
- alignItems: 'center',
496
- gap: 12,
497
- padding: '10px 12px',
498
- borderRadius: 8,
499
- textDecoration: 'none',
500
- color: '#B0413A',
501
- fontWeight: 500,
502
- fontSize: 13.5,
503
- }}
504
- >
505
- Abmelden
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
+ }