@frontera-sdk/core 1.50.84 → 1.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/core",
3
- "version": "1.50.84",
3
+ "version": "1.51.1",
4
4
  "description": "Frontera app runtime: the platform bridge client, app bootstrap and typed platform client.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -22,7 +22,13 @@ import {
22
22
  type FronteraAppMode,
23
23
  } from './app-session'
24
24
  import { applyHostTheme } from './theme'
25
- import { connectExternalSession, type ExternalAppSessionConfig } from './external-session'
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
 
@@ -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 a disabled person or an unreachable
153
- * identity provider costs one attempt per backoff step, not one per request.
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<BridgeSession & { appUser: AppUserSummary | null }> {
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'