@reflagged/shell 1.6.1 → 1.7.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
@@ -132,6 +132,22 @@ import { NotificationBell } from '@reflagged/shell/components/NotificationBell'
132
132
  <NotificationBell />
133
133
  ```
134
134
 
135
+ ## Releasing
136
+
137
+ Bump `version` in `package.json`, commit, tag `vX.Y.Z` and push the tag.
138
+ `publish.yml` puts it on npm; `fan-out.yml` then opens a bump PR in every
139
+ `rfl-gd` repo whose root `package.json` depends on this package and merges it
140
+ unless the bump breaks a CI job that passes on the repo's default branch (a
141
+ main that is red already does not block it; no CI at all merges, too). Re-run
142
+ a rollout via *Actions → Roll out @reflagged/shell → Run workflow*; it resumes
143
+ open PRs.
144
+
145
+ Needs the secret `SHELL_FANOUT_TOKEN`: a fine-grained token on `rfl-gd`, all
146
+ repositories, with Contents and Pull requests read/write, Commit statuses and
147
+ Actions read. `PACKAGES_READ_TOKEN` (`read:packages`) lets repos with private
148
+ `@rfl-gd` packages resolve their lockfile. Running tenant instances stay pinned and update one by one in
149
+ Base (Plattform → Updates).
150
+
135
151
  ## App palette
136
152
 
137
153
  `BrandSwitcher` opens on click or with ⌘K / Ctrl+K anywhere in the app. It
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reflagged/shell",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -30,7 +30,8 @@
30
30
  "./package.json": "./package.json",
31
31
  "./components/ServiceIcon": "./src/components/ServiceIcon.tsx",
32
32
  "./notifications/notifier": "./src/lib/notifications/notifier.ts",
33
- "./components/NotificationBell": "./src/components/NotificationBell.tsx"
33
+ "./components/NotificationBell": "./src/components/NotificationBell.tsx",
34
+ "./components/ImpersonationCapsule": "./src/components/ImpersonationCapsule.tsx"
34
35
  },
35
36
  "scripts": {
36
37
  "typecheck": "tsc --noEmit",
@@ -4,6 +4,7 @@ import { useEffect, useId, useMemo, useRef, useState } from 'react'
4
4
  import { createPortal } from 'react-dom'
5
5
  import { loadAppConfig } from '../config'
6
6
  import { baseSearchUrl, isPaletteShortcut, matchApps, type PaletteApp } from '../lib/palette/palette'
7
+ import { ImpersonationCapsule, type ImpersonationInfo } from './ImpersonationCapsule'
7
8
  import { ServiceIcon } from './ServiceIcon'
8
9
  import { useTenantTheme } from './TenantTheme'
9
10
 
@@ -43,6 +44,8 @@ type ShellInfo = {
43
44
  catalog?: Array<{ service: string; tagline: string | null }>
44
45
  catalogUrl: string
45
46
  baseUrl: string
47
+ /** Set while a platform admin acts as this user (rflgd-base ≥ Oct 2026). */
48
+ impersonation?: ImpersonationInfo | null
46
49
  }
47
50
 
48
51
  /**
@@ -156,8 +159,12 @@ export function BrandSwitcher() {
156
159
  useEffect(() => {
157
160
  let cancel = false
158
161
  fetch('/api/shell-info', { credentials: 'include' })
159
- .then((r) => {
162
+ .then(async (r) => {
160
163
  if (r.status === 401 || r.status === 403) {
164
+ // The platform ended this session (e.g. an impersonation was
165
+ // closed) and the route dropped it: reload into a fresh sign-in.
166
+ const body = (await r.json().catch(() => null)) as { error?: string } | null
167
+ if (!cancel && body?.error === 'session-ended') window.location.reload()
161
168
  setError(true)
162
169
  return null
163
170
  }
@@ -601,11 +608,14 @@ export function BrandSwitcher() {
601
608
  </div>
602
609
  ) : null
603
610
 
611
+ const capsule = mounted && shell?.impersonation ? <ImpersonationCapsule info={shell.impersonation} /> : null
612
+
604
613
  if (mounted && open && pos) {
605
614
  return (
606
615
  <div data-brand-switcher style={{ position: 'relative' }}>
607
616
  {trigger}
608
617
  {createPortal(popover, document.body)}
618
+ {capsule}
609
619
  </div>
610
620
  )
611
621
  }
@@ -614,6 +624,7 @@ export function BrandSwitcher() {
614
624
  <div data-brand-switcher style={{ position: 'relative' }}>
615
625
  {trigger}
616
626
  {popover}
627
+ {capsule}
617
628
  </div>
618
629
  )
619
630
  }
@@ -0,0 +1,307 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+
6
+ /**
7
+ * What rflgd-base sends in `impersonation` while a platform admin acts as a
8
+ * customer ("Einloggen als"). Absent otherwise, and from older platforms.
9
+ */
10
+ export type ImpersonationInfo = {
11
+ tenantName: string
12
+ userName: string
13
+ roleLabel: string | null
14
+ startedBy: string | null
15
+ /** Unix ms. */
16
+ expiresAt: number
17
+ /** Platform time when this was built; the countdown follows its clock. */
18
+ serverNow: number
19
+ platformUrl: string
20
+ /** PATCH extends by 30 minutes, DELETE ends — on the platform, with CORS. */
21
+ actionsUrl: string
22
+ }
23
+
24
+ const HOST_ID = 'rflgd-impersonation-capsule'
25
+
26
+ function clock(ms: number): string {
27
+ const total = Math.max(0, Math.floor(ms / 1000))
28
+ const h = Math.floor(total / 3600)
29
+ const m = Math.floor((total % 3600) / 60)
30
+ const s = total % 60
31
+ return `${h ? `${h}:` : ''}${String(m).padStart(h ? 2 : 1, '0')}:${String(s).padStart(2, '0')}`
32
+ }
33
+
34
+ const timeOfDay = (ms: number) =>
35
+ new Date(ms).toLocaleTimeString('de-DE', {
36
+ hour: '2-digit',
37
+ minute: '2-digit',
38
+ timeZone: 'Europe/Berlin',
39
+ })
40
+
41
+ const FONT = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif'
42
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'
43
+
44
+ const pill: React.CSSProperties = {
45
+ minHeight: 36,
46
+ padding: '0 12px',
47
+ borderRadius: 999,
48
+ border: '1px solid #57534e',
49
+ background: 'transparent',
50
+ color: '#e7e5e4',
51
+ font: `500 13px ${FONT}`,
52
+ cursor: 'pointer',
53
+ }
54
+
55
+ /**
56
+ * The support-session capsule: a small red tab glued to the bottom edge, so it
57
+ * never covers a module's header or menu. A click opens the details above it
58
+ * with "+30 min", "Zum Unternehmen" and "Beenden". A thin red frame marks the
59
+ * viewport. Rendered once per page even when an app mounts the switcher twice.
60
+ */
61
+ export function ImpersonationCapsule({ info }: { info: ImpersonationInfo }) {
62
+ const [host, setHost] = useState<HTMLElement | null>(null)
63
+ const [expiresAt, setExpiresAt] = useState(info.expiresAt)
64
+ const [skew] = useState(() => info.serverNow - Date.now())
65
+ const [now, setNow] = useState(info.serverNow)
66
+ const [open, setOpen] = useState(false)
67
+ const [busy, setBusy] = useState(false)
68
+ const [failed, setFailed] = useState<string | null>(null)
69
+ const remaining = expiresAt - now
70
+
71
+ // First instance on the page owns the capsule; a second switcher stays quiet.
72
+ useEffect(() => {
73
+ if (document.getElementById(HOST_ID)) return
74
+ const el = document.createElement('div')
75
+ el.id = HOST_ID
76
+ document.body.appendChild(el)
77
+ setHost(el)
78
+ return () => el.remove()
79
+ }, [])
80
+
81
+ useEffect(() => {
82
+ if (!host) return
83
+ const id = window.setInterval(() => setNow(Date.now() + skew), 1000)
84
+ return () => window.clearInterval(id)
85
+ }, [host, skew])
86
+
87
+ // Ran out: the platform refuses the session now; reloading signs in afresh.
88
+ useEffect(() => {
89
+ if (host && remaining <= 0) window.location.reload()
90
+ }, [host, remaining])
91
+
92
+ if (!host) return null
93
+
94
+ const call = async (method: 'PATCH' | 'DELETE') => {
95
+ const res = await fetch(info.actionsUrl, {
96
+ method,
97
+ credentials: 'include',
98
+ })
99
+ if (!res.ok) throw new Error(String(res.status))
100
+ return res
101
+ }
102
+
103
+ const extend = async () => {
104
+ setBusy(true)
105
+ setFailed(null)
106
+ try {
107
+ const data = (await (await call('PATCH')).json()) as {
108
+ expiresAt: number
109
+ }
110
+ setExpiresAt(data.expiresAt)
111
+ } catch {
112
+ setFailed('Verlängern hat nicht geklappt.')
113
+ } finally {
114
+ setBusy(false)
115
+ }
116
+ }
117
+
118
+ const end = async (toPlatform: boolean) => {
119
+ setBusy(true)
120
+ setFailed(null)
121
+ try {
122
+ await call('DELETE')
123
+ // The module session belongs to the customer; reloading lets the
124
+ // platform refuse it and the module sign in again.
125
+ if (toPlatform) window.location.assign(info.platformUrl)
126
+ else window.location.reload()
127
+ } catch {
128
+ setBusy(false)
129
+ setFailed('Beenden hat nicht geklappt.')
130
+ }
131
+ }
132
+
133
+ return createPortal(
134
+ <>
135
+ <div
136
+ aria-hidden
137
+ style={{
138
+ position: 'fixed',
139
+ inset: 0,
140
+ zIndex: 2147483000,
141
+ pointerEvents: 'none',
142
+ boxShadow: 'inset 0 0 0 2px #b91c1c',
143
+ }}
144
+ />
145
+ <div
146
+ data-testid="impersonation-banner"
147
+ style={{
148
+ position: 'fixed',
149
+ left: '50%',
150
+ bottom: 0,
151
+ transform: 'translateX(-50%)',
152
+ zIndex: 2147483001,
153
+ display: 'flex',
154
+ flexDirection: 'column',
155
+ alignItems: 'center',
156
+ gap: 8,
157
+ font: `13px ${FONT}`,
158
+ }}
159
+ >
160
+ {open ? (
161
+ <div
162
+ id={`${HOST_ID}-details`}
163
+ style={{
164
+ width: 'min(400px, calc(100vw - 24px))',
165
+ boxSizing: 'border-box',
166
+ borderRadius: 18,
167
+ background: '#1c1917',
168
+ color: '#e7e5e4',
169
+ boxShadow: '0 24px 60px rgba(28,25,23,0.35)',
170
+ padding: 16,
171
+ display: 'flex',
172
+ flexDirection: 'column',
173
+ gap: 14,
174
+ }}
175
+ >
176
+ <div>
177
+ <div style={{ fontSize: 14, fontWeight: 600, color: '#ffffff' }}>{info.userName}</div>
178
+ <div style={{ color: '#a8a29e' }}>
179
+ {info.roleLabel ? `${info.roleLabel} · ` : ''}
180
+ {info.tenantName}
181
+ </div>
182
+ </div>
183
+ <dl
184
+ style={{
185
+ margin: 0,
186
+ display: 'grid',
187
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
188
+ gap: '6px 16px',
189
+ }}
190
+ >
191
+ {info.startedBy ? (
192
+ <>
193
+ <dt style={{ color: '#a8a29e' }}>Gestartet von</dt>
194
+ <dd
195
+ style={{
196
+ margin: 0,
197
+ overflow: 'hidden',
198
+ textOverflow: 'ellipsis',
199
+ }}
200
+ >
201
+ {info.startedBy}
202
+ </dd>
203
+ </>
204
+ ) : null}
205
+ <dt style={{ color: '#a8a29e' }}>Endet um</dt>
206
+ <dd style={{ margin: 0 }}>{timeOfDay(expiresAt)} Uhr</dd>
207
+ </dl>
208
+ <p style={{ margin: 0, color: '#a8a29e' }}>Beim Beenden wirst du auch hier abgemeldet.</p>
209
+ {failed ? <p style={{ margin: 0, color: '#fca5a5' }}>{failed}</p> : null}
210
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
211
+ <button type="button" style={pill} disabled={busy} onClick={extend}>
212
+ +30 min
213
+ </button>
214
+ <button type="button" style={pill} disabled={busy} onClick={() => end(true)}>
215
+ Zum Unternehmen
216
+ </button>
217
+ <span style={{ flex: 1 }} />
218
+ <button
219
+ type="button"
220
+ disabled={busy}
221
+ onClick={() => end(false)}
222
+ data-testid="end-impersonation-button"
223
+ style={{
224
+ ...pill,
225
+ border: 'none',
226
+ background: '#fafaf9',
227
+ color: '#1c1917',
228
+ fontWeight: 600,
229
+ padding: '0 14px',
230
+ }}
231
+ >
232
+ {busy ? '…' : 'Beenden'}
233
+ </button>
234
+ </div>
235
+ </div>
236
+ ) : null}
237
+ <button
238
+ type="button"
239
+ onClick={() => setOpen((v) => !v)}
240
+ aria-expanded={open}
241
+ aria-controls={`${HOST_ID}-details`}
242
+ data-testid="impersonation-capsule"
243
+ style={{
244
+ display: 'flex',
245
+ alignItems: 'center',
246
+ gap: 10,
247
+ height: 30,
248
+ maxWidth: 'calc(100vw - 24px)',
249
+ padding: '0 8px 0 14px',
250
+ border: 'none',
251
+ borderRadius: '12px 12px 0 0',
252
+ background: '#b91c1c',
253
+ color: '#ffffff',
254
+ font: `500 12.5px ${FONT}`,
255
+ cursor: 'pointer',
256
+ boxShadow: '0 -6px 20px rgba(28,25,23,0.18)',
257
+ }}
258
+ >
259
+ <span
260
+ aria-hidden
261
+ style={{
262
+ width: 7,
263
+ height: 7,
264
+ borderRadius: 999,
265
+ background: '#fecaca',
266
+ flex: 'none',
267
+ }}
268
+ />
269
+ <span
270
+ style={{
271
+ overflow: 'hidden',
272
+ textOverflow: 'ellipsis',
273
+ whiteSpace: 'nowrap',
274
+ }}
275
+ >
276
+ Als <strong style={{ fontWeight: 600 }}>{info.userName}</strong> bei {info.tenantName}
277
+ </span>
278
+ <span
279
+ style={{
280
+ flex: 'none',
281
+ padding: '2px 8px',
282
+ borderRadius: 999,
283
+ background: 'rgba(255,255,255,0.16)',
284
+ font: `500 11.5px ${MONO}`,
285
+ }}
286
+ >
287
+ {clock(remaining)}
288
+ </span>
289
+ <svg
290
+ width="16"
291
+ height="16"
292
+ viewBox="0 0 24 24"
293
+ fill="none"
294
+ stroke="currentColor"
295
+ strokeWidth="2"
296
+ strokeLinecap="round"
297
+ aria-hidden
298
+ style={{ flex: 'none' }}
299
+ >
300
+ <path d={open ? 'M6 9l6 6 6-6' : 'M6 15l6-6 6 6'} />
301
+ </svg>
302
+ </button>
303
+ </div>
304
+ </>,
305
+ host,
306
+ )
307
+ }
@@ -103,6 +103,15 @@ export async function GET(req: Request): Promise<NextResponse> {
103
103
  }
104
104
  }
105
105
 
106
+ // Refused even after a refresh: the platform has ended this session (e.g. an
107
+ // impersonation the admin closed). Drop it so the module signs in afresh
108
+ // instead of carrying on as the customer.
109
+ if (upstream.status === 401) {
110
+ const ended = NextResponse.json({ error: 'session-ended' }, { status: 401 })
111
+ ended.cookies.set(OIDC_COOKIE, '', { path: '/', maxAge: 0 })
112
+ return ended
113
+ }
114
+
106
115
  if (!upstream.ok) {
107
116
  return NextResponse.json(
108
117
  { error: 'upstream-failed', status: upstream.status },