@reflagged/shell 1.5.0 → 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 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.0",
3
+ "version": "1.6.0",
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,110 @@ 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
+ e.preventDefault()
190
+ place()
191
+ setOpen((v) => !v)
136
192
  }
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])
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) return
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 () => document.removeEventListener('mousedown', onClick)
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 showWorkspaceSection = orgs.length > 1
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={handleToggle}
168
- aria-haspopup="menu"
275
+ onClick={toggle}
276
+ aria-haspopup="dialog"
169
277
  aria-expanded={open}
170
- aria-label="Reflagged Apps wechseln"
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: '#2C1E14',
303
+ color: INK,
196
304
  }}
197
305
  >
198
306
  {config.appLabel}
199
307
  </div>
200
308
  <div
201
309
  style={{
202
- fontFamily: "'JetBrains Mono', monospace",
310
+ fontFamily: MONO,
203
311
  fontSize: 10.5,
204
312
  letterSpacing: '0.1em',
205
- color: '#7A6A58',
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
- {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>
323
+ <span style={{ ...KBD, marginLeft: 4 }}>{shortcut}</span>
235
324
  </button>
236
325
  </div>
237
326
  )
238
327
 
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
- }
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="menu"
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: 280,
259
- borderRadius: 14,
424
+ width: 'min(640px, calc(100vw - 16px))',
425
+ borderRadius: 16,
260
426
  border: `1px solid ${tint(16)}`,
261
427
  background: '#FFFFFF',
262
- padding: 8,
263
- boxShadow: `0 1px 0 ${tint(8)}, 0 12px 40px rgba(0,0,0,0.15)`,
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
- {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}
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={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. */}
464
+ <div style={{ maxHeight: 'min(32rem, 70vh)', overflowY: 'auto', padding: 16, display: 'grid', gap: 16 }}>
465
+ {error && !shell ? (
350
466
  <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
- }}
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
- <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 }}>Reflagged 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
- {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 (
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
- 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
- }}
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
- <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
- )}
565
+ Unternehmen in Base wechseln →
474
566
  </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
- )}
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
+ }