@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.
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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'
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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 },
|