@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.
|
|
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 },
|