@frontera-sdk/core 1.50.84 → 1.51.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/package.json +1 -1
- package/src/create-frontera-app.tsx +136 -4
- package/src/external-session.ts +58 -5
- package/src/react.ts +3 -0
package/package.json
CHANGED
|
@@ -22,7 +22,13 @@ import {
|
|
|
22
22
|
type FronteraAppMode,
|
|
23
23
|
} from './app-session'
|
|
24
24
|
import { applyHostTheme } from './theme'
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
connectExternalSession,
|
|
27
|
+
isAccessRefusal,
|
|
28
|
+
type AccessRefusal,
|
|
29
|
+
type AccessRefusalReason,
|
|
30
|
+
type ExternalAppSessionConfig,
|
|
31
|
+
} from './external-session'
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
34
|
* The entry point every app shares.
|
|
@@ -368,6 +374,98 @@ function diagnostic(title: string, detail: string, variant: 'loading' | 'error'
|
|
|
368
374
|
)
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
/**
|
|
378
|
+
* What a person Frontera refused reads, one entry per refusal of the person.
|
|
379
|
+
* Plain and calm: this is not a crash, and the next step is someone else's.
|
|
380
|
+
*/
|
|
381
|
+
const ACCESS_COPY: Record<AccessRefusalReason, { title: string; detail: string }> = {
|
|
382
|
+
no_role: {
|
|
383
|
+
title: "You don't have access yet",
|
|
384
|
+
detail: "You don't have access to this App yet. Ask your administrator to give you a role.",
|
|
385
|
+
},
|
|
386
|
+
not_admitted: {
|
|
387
|
+
title: "You can't use this App",
|
|
388
|
+
detail: "Your account isn't allowed to sign in to this App. If you need it, ask your administrator.",
|
|
389
|
+
},
|
|
390
|
+
disabled: {
|
|
391
|
+
title: 'Your access is turned off',
|
|
392
|
+
detail: 'Your access to this App has been turned off. Ask your administrator if you need it back.',
|
|
393
|
+
},
|
|
394
|
+
not_linked: {
|
|
395
|
+
title: "We couldn't find your record",
|
|
396
|
+
detail: "This App couldn't match you to a record. Ask your administrator to link your account.",
|
|
397
|
+
},
|
|
398
|
+
link_ambiguous: {
|
|
399
|
+
title: 'Your account needs a quick fix',
|
|
400
|
+
detail: 'More than one record matches you. Ask your administrator to choose the right one.',
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The default screen for a person Frontera refused to sign in: a title, one
|
|
406
|
+
* sentence on what to do, and Try again. Deliberately unlike `diagnostic`'s
|
|
407
|
+
* error variant (no alert icon, no monospaced message) because nothing is
|
|
408
|
+
* broken. The service's own message stays out: it is written for the App's
|
|
409
|
+
* builder, not the person.
|
|
410
|
+
*/
|
|
411
|
+
function accessRefusedScreen(error: AccessRefusal, retry: () => void): ReactNode {
|
|
412
|
+
const copy = ACCESS_COPY[error.reason]
|
|
413
|
+
return (
|
|
414
|
+
<div
|
|
415
|
+
role="status"
|
|
416
|
+
data-frontera-access-refused={error.reason}
|
|
417
|
+
style={{
|
|
418
|
+
boxSizing: 'border-box',
|
|
419
|
+
minHeight: '100dvh',
|
|
420
|
+
display: 'flex',
|
|
421
|
+
alignItems: 'center',
|
|
422
|
+
justifyContent: 'center',
|
|
423
|
+
padding: 32,
|
|
424
|
+
fontFamily: 'var(--font-sans, system-ui, sans-serif)',
|
|
425
|
+
background: BOOT_PALETTE.background,
|
|
426
|
+
lineHeight: 1.5,
|
|
427
|
+
}}
|
|
428
|
+
>
|
|
429
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center', maxWidth: 380 }}>
|
|
430
|
+
<svg
|
|
431
|
+
aria-hidden="true"
|
|
432
|
+
viewBox="0 0 24 24"
|
|
433
|
+
width={30}
|
|
434
|
+
height={30}
|
|
435
|
+
fill="none"
|
|
436
|
+
stroke={BOOT_PALETTE.muted}
|
|
437
|
+
strokeWidth={2}
|
|
438
|
+
strokeLinecap="round"
|
|
439
|
+
strokeLinejoin="round"
|
|
440
|
+
style={{ marginBottom: 6 }}
|
|
441
|
+
>
|
|
442
|
+
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
443
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
444
|
+
</svg>
|
|
445
|
+
<div style={{ fontWeight: 'bold' as const, fontSize: 18 }}>{copy.title}</div>
|
|
446
|
+
<div style={{ fontSize: 14, color: BOOT_PALETTE.muted }}>{copy.detail}</div>
|
|
447
|
+
<button
|
|
448
|
+
type="button"
|
|
449
|
+
onClick={retry}
|
|
450
|
+
style={{
|
|
451
|
+
marginTop: 6,
|
|
452
|
+
padding: '6px 14px',
|
|
453
|
+
font: 'inherit',
|
|
454
|
+
fontSize: 14,
|
|
455
|
+
color: 'inherit',
|
|
456
|
+
background: BOOT_PALETTE.surface,
|
|
457
|
+
border: `1px solid ${BOOT_PALETTE.border}`,
|
|
458
|
+
borderRadius: 'calc(var(--radius, 8px) - 2px)',
|
|
459
|
+
cursor: 'pointer',
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
Try again
|
|
463
|
+
</button>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
|
|
371
469
|
/**
|
|
372
470
|
* Holds what the host can change underneath a running app.
|
|
373
471
|
*
|
|
@@ -446,7 +544,18 @@ export interface FronteraAppProviderProps {
|
|
|
446
544
|
children: ReactNode
|
|
447
545
|
providers?: FronteraProvider[]
|
|
448
546
|
loading?: ReactNode
|
|
547
|
+
/** Shown when the App cannot start or crashes while rendering. */
|
|
449
548
|
errorFallback?: (error: Error) => ReactNode
|
|
549
|
+
/**
|
|
550
|
+
* External Apps only: shown instead of the App when Frontera refuses the
|
|
551
|
+
* person, at start or when a later renewal is refused. `error.reason` is
|
|
552
|
+
* one of `no_role`, `not_admitted`, `disabled`, `not_linked` or
|
|
553
|
+
* `link_ambiguous`. `retry` signs in again. The default is a plain screen
|
|
554
|
+
* that tells the person to ask their administrator. Every other sign-in
|
|
555
|
+
* failure (a fault in the App's setup, `invalid_token`,
|
|
556
|
+
* `issuer_unreachable`, no reason) still reaches `errorFallback`.
|
|
557
|
+
*/
|
|
558
|
+
accessDenied?: (error: AccessRefusal, retry: () => void) => ReactNode
|
|
450
559
|
/** Compatibility hooks used by createFronteraApp. */
|
|
451
560
|
queryClient?: QueryClient
|
|
452
561
|
timeoutMs?: number
|
|
@@ -469,6 +578,7 @@ export function FronteraAppProvider({
|
|
|
469
578
|
providers = [],
|
|
470
579
|
loading = diagnostic('Connecting to Frontera', 'Establishing an authenticated App session.', 'loading'),
|
|
471
580
|
errorFallback,
|
|
581
|
+
accessDenied = accessRefusedScreen,
|
|
472
582
|
queryClient: providedQueryClient,
|
|
473
583
|
timeoutMs,
|
|
474
584
|
session: externalSession,
|
|
@@ -485,6 +595,16 @@ export function FronteraAppProvider({
|
|
|
485
595
|
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 1 } },
|
|
486
596
|
}),
|
|
487
597
|
)
|
|
598
|
+
// Bumped by Try again on the access screen: signs in from scratch. The
|
|
599
|
+
// cache is emptied first, so the App never shows rows read by the session
|
|
600
|
+
// that was refused.
|
|
601
|
+
const [attempt, setAttempt] = useState(0)
|
|
602
|
+
const retry = useCallback(() => {
|
|
603
|
+
queryClient.clear()
|
|
604
|
+
setConnection(null)
|
|
605
|
+
setError(null)
|
|
606
|
+
setAttempt((n) => n + 1)
|
|
607
|
+
}, [queryClient])
|
|
488
608
|
|
|
489
609
|
useEffect(() => {
|
|
490
610
|
const runtime = readRuntimeGlobal()
|
|
@@ -508,7 +628,18 @@ export function FronteraAppProvider({
|
|
|
508
628
|
let active = true
|
|
509
629
|
let sessionToDispose: BridgeSession | null = null
|
|
510
630
|
const connected: Promise<BridgeSession> = externalSession
|
|
511
|
-
? connectExternalSession(externalSession)
|
|
631
|
+
? connectExternalSession(externalSession).then((session) => {
|
|
632
|
+
// Mid-session: signing in again was refused (a role removed, the
|
|
633
|
+
// person disabled). The App's data is no longer theirs to see.
|
|
634
|
+
session.onRefused((refusal) => {
|
|
635
|
+
if (!active) return
|
|
636
|
+
session.dispose()
|
|
637
|
+
queryClient.clear()
|
|
638
|
+
setConnection(null)
|
|
639
|
+
setError(refusal)
|
|
640
|
+
})
|
|
641
|
+
return session
|
|
642
|
+
})
|
|
512
643
|
: mode === 'embedded'
|
|
513
644
|
? connectToHost({ parentOrigin: runtime.platformOrigin, timeoutMs })
|
|
514
645
|
: connectToSessionEndpoint(
|
|
@@ -534,9 +665,10 @@ export function FronteraAppProvider({
|
|
|
534
665
|
}
|
|
535
666
|
// Read once at mount, like the runtime global: a session is established
|
|
536
667
|
// for the page's life, and a new config object each render must not
|
|
537
|
-
// reconnect.
|
|
538
|
-
}, [])
|
|
668
|
+
// reconnect. Only Try again (`attempt`) starts another.
|
|
669
|
+
}, [attempt])
|
|
539
670
|
|
|
671
|
+
if (isAccessRefusal(error)) return accessDenied(error, retry)
|
|
540
672
|
if (error) return errorFallback?.(error) ?? diagnostic('Frontera App could not start', error.message)
|
|
541
673
|
if (!connection) return loading
|
|
542
674
|
|
package/src/external-session.ts
CHANGED
|
@@ -61,6 +61,29 @@ export class AppUserSignInError extends Error {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The refusals that are about the person: Frontera knows who they are and
|
|
66
|
+
* will not let them in. Signing in again gives the same answer until an
|
|
67
|
+
* administrator changes something, so a running session stops renewing and
|
|
68
|
+
* the App shows the refusal.
|
|
69
|
+
*
|
|
70
|
+
* An allow-list, so a reason added to the service later is not shown to
|
|
71
|
+
* people as "you have no access" by accident. Everything else is the App's
|
|
72
|
+
* to handle as before: a fault in the App's setup (`app_not_found`,
|
|
73
|
+
* `audience_disabled`, `key_invalid`, `audience_misconfigured`), a token that
|
|
74
|
+
* did not verify (`invalid_token`: the App may need to send the person to its
|
|
75
|
+
* login), the identity provider being down (`issuer_unreachable`), and a
|
|
76
|
+
* failure with no reason (a rate limit, a server fault, the App's own session
|
|
77
|
+
* endpoint).
|
|
78
|
+
*/
|
|
79
|
+
export const ACCESS_REFUSAL_REASONS = ['no_role', 'not_admitted', 'disabled', 'not_linked', 'link_ambiguous'] as const
|
|
80
|
+
export type AccessRefusalReason = (typeof ACCESS_REFUSAL_REASONS)[number]
|
|
81
|
+
export type AccessRefusal = AppUserSignInError & { readonly reason: AccessRefusalReason }
|
|
82
|
+
|
|
83
|
+
export function isAccessRefusal(error: unknown): error is AccessRefusal {
|
|
84
|
+
return error instanceof AppUserSignInError && (ACCESS_REFUSAL_REASONS as readonly (string | null)[]).includes(error.reason)
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
type FetchImpl = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
65
88
|
|
|
66
89
|
function unwrap(body: unknown): Record<string, unknown> {
|
|
@@ -145,17 +168,28 @@ const MIN_RENEW_DELAY_MS = 30_000
|
|
|
145
168
|
const FIRST_BACKOFF_MS = 30_000
|
|
146
169
|
const MAX_BACKOFF_MS = 5 * 60_000
|
|
147
170
|
|
|
171
|
+
export interface ExternalSession extends BridgeSession {
|
|
172
|
+
appUser: AppUserSummary | null
|
|
173
|
+
/**
|
|
174
|
+
* Called once when signing in again is refused for the person's own reason
|
|
175
|
+
* (see `isAccessRefusal`). The session has stopped renewing by then.
|
|
176
|
+
*/
|
|
177
|
+
onRefused(handler: (error: AccessRefusal) => void): () => void
|
|
178
|
+
}
|
|
179
|
+
|
|
148
180
|
/**
|
|
149
181
|
* A bridge-compatible session for an externally hosted App: signs in once,
|
|
150
182
|
* then renews the App-user token at 80% of its life. A request refused with
|
|
151
183
|
* 401 signs in again once. A failed sign-in is remembered: until its backoff
|
|
152
|
-
* passes, no request triggers another, so
|
|
153
|
-
*
|
|
184
|
+
* passes, no request triggers another, so an unreachable identity provider
|
|
185
|
+
* costs one attempt per backoff step, not one per request. Only a refusal of
|
|
186
|
+
* the person (`isAccessRefusal`: no role, disabled, …) ends renewal for good
|
|
187
|
+
* and is reported to `onRefused`.
|
|
154
188
|
*/
|
|
155
189
|
export async function connectExternalSession(
|
|
156
190
|
config: ExternalAppSessionConfig,
|
|
157
191
|
options: ExternalSessionOptions = {},
|
|
158
|
-
): Promise<
|
|
192
|
+
): Promise<ExternalSession> {
|
|
159
193
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
160
194
|
const now = options.now ?? Date.now
|
|
161
195
|
const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))
|
|
@@ -163,6 +197,8 @@ export async function connectExternalSession(
|
|
|
163
197
|
|
|
164
198
|
const first = await nextToken(config, fetchImpl, now)
|
|
165
199
|
const tokenHandlers = new Set<(token: string) => void>()
|
|
200
|
+
const refusalHandlers = new Set<(error: AccessRefusal) => void>()
|
|
201
|
+
let refused: AccessRefusal | null = null
|
|
166
202
|
let disposed = false
|
|
167
203
|
let timer: unknown
|
|
168
204
|
let currentToken = first.token
|
|
@@ -195,7 +231,7 @@ export async function connectExternalSession(
|
|
|
195
231
|
* share one exchange.
|
|
196
232
|
*/
|
|
197
233
|
const renewNow = (): Promise<string | null> => {
|
|
198
|
-
if (disposed) return Promise.resolve(null)
|
|
234
|
+
if (disposed || refused) return Promise.resolve(null)
|
|
199
235
|
if (renewing) return renewing
|
|
200
236
|
if (now() < retryAfter) return Promise.resolve(null)
|
|
201
237
|
renewing = nextToken(config, fetchImpl, now)
|
|
@@ -207,7 +243,15 @@ export async function connectExternalSession(
|
|
|
207
243
|
schedule(next.expiresAt)
|
|
208
244
|
return next.token
|
|
209
245
|
})
|
|
210
|
-
.catch(() => {
|
|
246
|
+
.catch((error: unknown) => {
|
|
247
|
+
if (disposed) return null
|
|
248
|
+
if (isAccessRefusal(error)) {
|
|
249
|
+
refused = error
|
|
250
|
+
if (timer !== undefined) clearTimer(timer)
|
|
251
|
+
timer = undefined
|
|
252
|
+
for (const handler of refusalHandlers) handler(error)
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
211
255
|
const backoff = Math.min(MAX_BACKOFF_MS, FIRST_BACKOFF_MS * 2 ** failures)
|
|
212
256
|
failures++
|
|
213
257
|
retryAfter = now() + backoff
|
|
@@ -263,6 +307,14 @@ export async function connectExternalSession(
|
|
|
263
307
|
tokenHandlers.add(handler)
|
|
264
308
|
return () => tokenHandlers.delete(handler)
|
|
265
309
|
},
|
|
310
|
+
onRefused(handler) {
|
|
311
|
+
if (refused) {
|
|
312
|
+
handler(refused)
|
|
313
|
+
return () => {}
|
|
314
|
+
}
|
|
315
|
+
refusalHandlers.add(handler)
|
|
316
|
+
return () => refusalHandlers.delete(handler)
|
|
317
|
+
},
|
|
266
318
|
onTheme() {
|
|
267
319
|
return () => {}
|
|
268
320
|
},
|
|
@@ -273,6 +325,7 @@ export async function connectExternalSession(
|
|
|
273
325
|
disposed = true
|
|
274
326
|
if (timer !== undefined) clearTimer(timer)
|
|
275
327
|
tokenHandlers.clear()
|
|
328
|
+
refusalHandlers.clear()
|
|
276
329
|
},
|
|
277
330
|
}
|
|
278
331
|
}
|
package/src/react.ts
CHANGED
|
@@ -11,6 +11,9 @@ export type { FronteraViewer } from './bridge-protocol'
|
|
|
11
11
|
export {
|
|
12
12
|
AppUserSignInError,
|
|
13
13
|
exchangeCustomerToken,
|
|
14
|
+
isAccessRefusal,
|
|
15
|
+
type AccessRefusal,
|
|
16
|
+
type AccessRefusalReason,
|
|
14
17
|
type AppUserSummary,
|
|
15
18
|
type ExternalAppSessionConfig,
|
|
16
19
|
} from './external-session'
|