@reflagged/shell 1.6.1 → 1.7.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
@@ -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.1",
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
+ }
@@ -55,13 +55,26 @@ export async function GET(req: Request): Promise<NextResponse> {
55
55
 
56
56
  const params = oauth.validateAuthResponse(as, client, url, pkce.state)
57
57
 
58
- const tokenResponse = await oauth.authorizationCodeGrantRequest(
59
- as, client, clientAuth, params, env.redirectUri, pkce.codeVerifier,
60
- )
61
- const result = await oauth.processAuthorizationCodeResponse(as, client, tokenResponse, {
62
- expectedNonce: pkce.nonce,
63
- requireIdToken: true,
64
- })
58
+ // The provider issued a code and may still refuse to exchange it. Seen live:
59
+ // after an "Einloggen als" session ended, the browser's provider session
60
+ // still belonged to the impersonated account, every code was minted for it,
61
+ // and every exchange came back invalid_grant — an unhandled throw, so a bare
62
+ // 500 on each reload.
63
+ let result: Awaited<ReturnType<typeof oauth.processAuthorizationCodeResponse>>
64
+ try {
65
+ const tokenResponse = await oauth.authorizationCodeGrantRequest(
66
+ as, client, clientAuth, params, env.redirectUri, pkce.codeVerifier,
67
+ )
68
+ result = await oauth.processAuthorizationCodeResponse(as, client, tokenResponse, {
69
+ expectedNonce: pkce.nonce,
70
+ requireIdToken: true,
71
+ })
72
+ } catch (error) {
73
+ if (!(error instanceof oauth.ResponseBodyError)) throw error
74
+ const res = deniedResponse(error.error, error.error_description ?? null)
75
+ res.cookies.delete(OIDC_STATE_COOKIE)
76
+ return res
77
+ }
65
78
 
66
79
  const claims = oauth.getValidatedIdTokenClaims(result)
67
80
  if (!claims) return NextResponse.json({ error: 'no-id-token' }, { status: 400 })
@@ -123,13 +136,25 @@ const escapeHtml = (v: string): string =>
123
136
  */
124
137
  export function deniedResponse(error: string, description: string | null): NextResponse {
125
138
  const status = error === 'access_denied' ? 403 : 400
139
+ // invalid_grant: the code belongs to a provider session that is no longer
140
+ // valid. Trying again lands in that same session, so the way out is to end
141
+ // it — signing out at the platform — not to ask an administrator.
142
+ const expired = error === 'invalid_grant'
126
143
  const headline =
127
- error === 'access_denied' ? 'Kein Zugriff auf diese Anwendung' : 'Anmeldung fehlgeschlagen'
128
- const detail =
129
- description?.trim() ||
130
- (error === 'access_denied'
131
- ? 'Ihr Account ist dieser Anwendung nicht zugeordnet.'
132
- : `Der Anmeldedienst hat die Anmeldung abgelehnt (${error}).`)
144
+ error === 'access_denied'
145
+ ? 'Kein Zugriff auf diese Anwendung'
146
+ : expired
147
+ ? 'Anmeldung abgelaufen'
148
+ : 'Anmeldung fehlgeschlagen'
149
+ const detail = expired
150
+ ? 'Die Anmeldung gehört zu einer Sitzung, die nicht mehr gilt — etwa nach dem Ende von „Einloggen als“. Melden Sie sich bei der Plattform ab und danach neu an.'
151
+ : description?.trim() ||
152
+ (error === 'access_denied'
153
+ ? 'Ihr Account ist dieser Anwendung nicht zugeordnet.'
154
+ : `Der Anmeldedienst hat die Anmeldung abgelehnt (${error}).`)
155
+ const next = expired
156
+ ? '<p><a href="/api/oidc/signout">Bei der Plattform abmelden</a></p>'
157
+ : '<p>Bitten Sie eine Administratorin oder einen Administrator Ihrer Organisation, Ihren Account für diese Anwendung freizuschalten.</p>\n <p><a href="/">Zur Startseite</a></p>'
133
158
  const html = `<!doctype html>
134
159
  <html lang="de">
135
160
  <head>
@@ -150,8 +175,7 @@ export function deniedResponse(error: string, description: string | null): NextR
150
175
  <main>
151
176
  <h1>${escapeHtml(headline)}</h1>
152
177
  <p>${escapeHtml(detail)}</p>
153
- <p>Bitten Sie eine Administratorin oder einen Administrator Ihrer Organisation, Ihren Account für diese Anwendung freizuschalten.</p>
154
- <p><a href="/">Zur Startseite</a></p>
178
+ ${next}
155
179
  <p><small>Fehlercode: ${escapeHtml(error)}</small></p>
156
180
  </main>
157
181
  </body>
@@ -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 },