@byline/host-tanstack-start 5.1.0 → 5.1.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.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ /**
10
+ * Guarantees a translation context for chrome that can render *outside*
11
+ * the admin layout's `<I18nProvider>`.
12
+ *
13
+ * When a provider is already above us it is reused as-is, preserving the
14
+ * user's active locale. Only when none is present does this self-mount.
15
+ *
16
+ * Resilient by design: components using this are the ones shown when
17
+ * something has already gone wrong, so throwing here would mask the real
18
+ * condition. If the admin config can't be read it falls back to an
19
+ * English-default provider with an empty bundle — with a provider always
20
+ * present, `useTranslation` never throws, and a missing key renders as the
21
+ * raw key rather than crashing (see `I18nProvider`'s `onMissing`).
22
+ *
23
+ * `route-error.tsx` carries its own equivalent copy of this logic; it was
24
+ * left in place deliberately rather than refactored alongside an unrelated
25
+ * change, and can adopt this component whenever that file is next touched.
26
+ */
27
+ export declare function OptionalI18nProvider({ children }: {
28
+ children: ReactNode;
29
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { Fragment, jsx } from "react/jsx-runtime";
2
+ import { useContext } from "react";
3
+ import { getAdminConfig } from "@byline/core";
4
+ import { I18nContext, I18nProvider } from "@byline/i18n/react";
5
+ function OptionalI18nProvider({ children }) {
6
+ if (null != useContext(I18nContext)) return /*#__PURE__*/ jsx(Fragment, {
7
+ children: children
8
+ });
9
+ let bundle = {};
10
+ let locale = 'en';
11
+ try {
12
+ const { i18n } = getAdminConfig();
13
+ bundle = i18n.translations ?? {};
14
+ locale = i18n.admin.defaultLocale;
15
+ } catch {}
16
+ return /*#__PURE__*/ jsx(I18nProvider, {
17
+ bundle: bundle,
18
+ activeLocale: locale,
19
+ defaultLocale: locale,
20
+ localeDefinitions: [],
21
+ children: children
22
+ });
23
+ }
24
+ export { OptionalI18nProvider };
@@ -1,8 +1,13 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState, useSyncExternalStore } from "react";
3
+ import { useTranslation } from "@byline/i18n/react";
4
+ import { Button } from "@byline/ui/react";
5
+ import clsx from "clsx";
3
6
  import { acceptSession, expectedSession, flagSessionChanged, observeSession, sessionEpoch, sessionIsChanged, startSessionNotifications, subscribeSessionChange } from "../../integrations/session-coordination.js";
4
7
  import { getSignInRoutePath } from "../../routes/sign-in-path.js";
5
8
  import { getCurrentAdminUser } from "../../server-fns/auth/current-user.js";
9
+ import { OptionalI18nProvider } from "./optional-i18n-provider.js";
10
+ import session_change_boundary_module from "./session-change-boundary.module.js";
6
11
  function SessionChangeBoundary({ user, children }) {
7
12
  const epoch = useSyncExternalStore(subscribeSessionChange, sessionEpoch, ()=>0);
8
13
  const changed = sessionIsChanged();
@@ -24,7 +29,7 @@ function SessionChangeBoundary({ user, children }) {
24
29
  if (sessionEpoch() !== epoch) return;
25
30
  setCurrent(verified);
26
31
  setError(void 0);
27
- }).catch(()=>setError('The session is no longer available. Please sign in again.'));
32
+ }).catch(()=>setError('sessionChange.errors.unavailable'));
28
33
  }, [
29
34
  changed,
30
35
  mismatch,
@@ -37,51 +42,78 @@ function SessionChangeBoundary({ user, children }) {
37
42
  const verified = await getCurrentAdminUser();
38
43
  if (!current || current.sessionId !== verified.sessionId) {
39
44
  setCurrent(verified);
40
- setError('The active session changed again. Review the account before continuing.');
45
+ setError('sessionChange.errors.changedAgain');
41
46
  return;
42
47
  }
43
48
  acceptSession(verified.sessionId);
44
49
  window.location.reload();
45
50
  } catch {
46
51
  flagSessionChanged();
47
- setError('Unable to verify the active session. Please sign in again.');
52
+ setError('sessionChange.errors.unverifiable');
48
53
  } finally{
49
54
  setBusy(false);
50
55
  }
51
56
  }
52
- return /*#__PURE__*/ jsxs("main", {
57
+ return /*#__PURE__*/ jsx(OptionalI18nProvider, {
58
+ children: /*#__PURE__*/ jsx(SessionChangeInterstitial, {
59
+ current: current,
60
+ errorKey: error,
61
+ busy: busy,
62
+ onAcknowledge: acknowledge
63
+ })
64
+ });
65
+ }
66
+ function SessionChangeInterstitial({ current, errorKey, busy, onAcknowledge }) {
67
+ const { t } = useTranslation('byline-admin');
68
+ return /*#__PURE__*/ jsx("main", {
69
+ className: clsx('byline-session-change', session_change_boundary_module.main),
53
70
  role: "alertdialog",
54
71
  "aria-modal": "true",
55
72
  "aria-labelledby": "session-change-title",
56
- children: [
57
- /*#__PURE__*/ jsx("h1", {
58
- id: "session-change-title",
59
- children: "Your session has changed"
60
- }),
61
- /*#__PURE__*/ jsx("p", {
62
- children: "Your previous work has not been retried. Check the active account before continuing."
63
- }),
64
- current && /*#__PURE__*/ jsxs("p", {
65
- children: [
66
- "Active account: ",
67
- current.email
68
- ]
69
- }),
70
- error && /*#__PURE__*/ jsx("p", {
71
- role: "alert",
72
- children: error
73
- }),
74
- /*#__PURE__*/ jsx("button", {
75
- type: "button",
76
- disabled: busy || !current,
77
- onClick: acknowledge,
78
- children: "Continue as this account"
79
- }),
80
- /*#__PURE__*/ jsx("a", {
81
- href: `${getSignInRoutePath()}?reauthenticate=1`,
82
- children: "Sign in again"
83
- })
84
- ]
73
+ children: /*#__PURE__*/ jsxs("div", {
74
+ className: clsx('byline-session-change-card', session_change_boundary_module.card),
75
+ children: [
76
+ /*#__PURE__*/ jsx("h1", {
77
+ id: "session-change-title",
78
+ className: clsx('byline-session-change-title', session_change_boundary_module.title),
79
+ children: t('sessionChange.title')
80
+ }),
81
+ /*#__PURE__*/ jsx("p", {
82
+ className: clsx('byline-session-change-body', session_change_boundary_module.body),
83
+ children: t('sessionChange.body')
84
+ }),
85
+ current && /*#__PURE__*/ jsx("p", {
86
+ className: clsx('byline-session-change-account', session_change_boundary_module.account),
87
+ children: t('sessionChange.activeAccount', {
88
+ email: current.email
89
+ })
90
+ }),
91
+ errorKey && /*#__PURE__*/ jsx("p", {
92
+ className: clsx('byline-session-change-error', session_change_boundary_module.error),
93
+ role: "alert",
94
+ children: t(errorKey)
95
+ }),
96
+ /*#__PURE__*/ jsxs("div", {
97
+ className: clsx('byline-session-change-actions', session_change_boundary_module.actions),
98
+ children: [
99
+ /*#__PURE__*/ jsx(Button, {
100
+ type: "button",
101
+ disabled: busy || !current,
102
+ onClick: onAcknowledge,
103
+ children: t('sessionChange.continue')
104
+ }),
105
+ /*#__PURE__*/ jsx(Button, {
106
+ variant: "outlined",
107
+ intent: "noeffect",
108
+ render: /*#__PURE__*/ jsx("a", {
109
+ href: `${getSignInRoutePath()}?reauthenticate=1`
110
+ }),
111
+ children: t('sessionChange.signInAgain')
112
+ })
113
+ ]
114
+ })
115
+ ]
116
+ })
85
117
  });
86
118
  }
87
119
  export { SessionChangeBoundary };
@@ -0,0 +1,11 @@
1
+ import "./session-change-boundary_module.css";
2
+ const session_change_boundary_module = {
3
+ main: "main-MNvpLI",
4
+ card: "card-BMYD6l",
5
+ title: "title-N8HIe4",
6
+ body: "body-WE9gi2",
7
+ account: "account-ZFcz_R",
8
+ error: "error-GXrBRW",
9
+ actions: "actions-O152EZ"
10
+ };
11
+ export default session_change_boundary_module;
@@ -0,0 +1,81 @@
1
+ :is(.main-MNvpLI, .byline-session-change) {
2
+ justify-content: center;
3
+ align-items: center;
4
+ min-height: 100dvh;
5
+ padding: 1.5rem 1.5rem calc(1.5rem + 12vh);
6
+ display: flex;
7
+ }
8
+
9
+ :is(.card-BMYD6l, .byline-session-change-card) {
10
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
11
+ border-radius: var(--border-radius-md);
12
+ background-color: var(--canvas-25);
13
+ flex-direction: column;
14
+ gap: .75rem;
15
+ width: 100%;
16
+ max-width: 30rem;
17
+ padding: 1.5rem;
18
+ display: flex;
19
+ }
20
+
21
+ :is(:is([data-theme="dark"], .dark) .card-BMYD6l, :is([data-theme="dark"], .dark) .byline-session-change-card) {
22
+ border-color: var(--gray-700);
23
+ background-color: var(--canvas-800);
24
+ }
25
+
26
+ :is(.title-N8HIe4, .byline-session-change-title) {
27
+ font-size: var(--font-size-xl);
28
+ font-weight: var(--font-weight-semibold);
29
+ line-height: var(--line-height-snug);
30
+ letter-spacing: var(--letter-spacing-tight);
31
+ color: var(--gray-900);
32
+ margin: 0;
33
+ }
34
+
35
+ :is(:is([data-theme="dark"], .dark) .title-N8HIe4, :is([data-theme="dark"], .dark) .byline-session-change-title) {
36
+ color: var(--gray-100);
37
+ }
38
+
39
+ :is(.body-WE9gi2, .byline-session-change-body) {
40
+ font-size: var(--font-size-sm);
41
+ line-height: var(--line-height-relaxed);
42
+ color: var(--gray-700);
43
+ margin: 0;
44
+ }
45
+
46
+ :is(:is([data-theme="dark"], .dark) .body-WE9gi2, :is([data-theme="dark"], .dark) .byline-session-change-body) {
47
+ color: var(--gray-400);
48
+ }
49
+
50
+ :is(.account-ZFcz_R, .byline-session-change-account) {
51
+ font-size: var(--font-size-sm);
52
+ font-weight: var(--font-weight-medium);
53
+ line-height: var(--line-height-normal);
54
+ color: var(--gray-900);
55
+ overflow-wrap: anywhere;
56
+ margin: 0;
57
+ }
58
+
59
+ :is(:is([data-theme="dark"], .dark) .account-ZFcz_R, :is([data-theme="dark"], .dark) .byline-session-change-account) {
60
+ color: var(--gray-100);
61
+ }
62
+
63
+ :is(.error-GXrBRW, .byline-session-change-error) {
64
+ font-size: var(--font-size-sm);
65
+ line-height: var(--line-height-relaxed);
66
+ color: var(--red-600);
67
+ margin: 0;
68
+ }
69
+
70
+ :is(:is([data-theme="dark"], .dark) .error-GXrBRW, :is([data-theme="dark"], .dark) .byline-session-change-error) {
71
+ color: var(--red-400);
72
+ }
73
+
74
+ :is(.actions-O152EZ, .byline-session-change-actions) {
75
+ flex-wrap: wrap;
76
+ align-items: center;
77
+ gap: .75rem;
78
+ margin-top: .25rem;
79
+ display: flex;
80
+ }
81
+
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "5.1.0",
6
+ "version": "5.1.1",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -125,15 +125,15 @@
125
125
  "react-json-view-lite": "^2.5.0",
126
126
  "react-swipeable": "^7.0.2",
127
127
  "zod": "^4.5.4",
128
- "@byline/admin": "5.1.0",
129
- "@byline/analytics-agent": "5.1.0",
130
- "@byline/analytics": "5.1.0",
131
- "@byline/client": "5.1.0",
132
- "@byline/ai": "5.1.0",
133
- "@byline/auth": "5.1.0",
134
- "@byline/core": "5.1.0",
135
- "@byline/i18n": "5.1.0",
136
- "@byline/ui": "5.1.0"
128
+ "@byline/admin": "5.1.1",
129
+ "@byline/ai": "5.1.1",
130
+ "@byline/auth": "5.1.1",
131
+ "@byline/analytics": "5.1.1",
132
+ "@byline/analytics-agent": "5.1.1",
133
+ "@byline/client": "5.1.1",
134
+ "@byline/core": "5.1.1",
135
+ "@byline/i18n": "5.1.1",
136
+ "@byline/ui": "5.1.1"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@tanstack/react-router": "^1.167.0",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { type ReactNode, useContext } from 'react'
10
+
11
+ import { getAdminConfig } from '@byline/core'
12
+ import type { TranslationBundle } from '@byline/i18n'
13
+ import { I18nContext, I18nProvider } from '@byline/i18n/react'
14
+
15
+ /**
16
+ * Guarantees a translation context for chrome that can render *outside*
17
+ * the admin layout's `<I18nProvider>`.
18
+ *
19
+ * When a provider is already above us it is reused as-is, preserving the
20
+ * user's active locale. Only when none is present does this self-mount.
21
+ *
22
+ * Resilient by design: components using this are the ones shown when
23
+ * something has already gone wrong, so throwing here would mask the real
24
+ * condition. If the admin config can't be read it falls back to an
25
+ * English-default provider with an empty bundle — with a provider always
26
+ * present, `useTranslation` never throws, and a missing key renders as the
27
+ * raw key rather than crashing (see `I18nProvider`'s `onMissing`).
28
+ *
29
+ * `route-error.tsx` carries its own equivalent copy of this logic; it was
30
+ * left in place deliberately rather than refactored alongside an unrelated
31
+ * change, and can adopt this component whenever that file is next touched.
32
+ */
33
+ export function OptionalI18nProvider({ children }: { children: ReactNode }) {
34
+ // Already inside a provider — reuse it (keeps the active locale) and skip
35
+ // the self-mount entirely.
36
+ if (useContext(I18nContext) != null) {
37
+ return <>{children}</>
38
+ }
39
+ let bundle: TranslationBundle = {}
40
+ let locale = 'en'
41
+ try {
42
+ const { i18n } = getAdminConfig()
43
+ bundle = i18n.translations ?? {}
44
+ // No active provider, so render in the default locale — recovering the
45
+ // per-user active locale isn't worth another lookup that could itself throw.
46
+ locale = i18n.admin.defaultLocale
47
+ } catch {
48
+ // Config unavailable (very early boot / broken provider chain) — fall
49
+ // through with the English defaults above.
50
+ }
51
+ return (
52
+ <I18nProvider
53
+ bundle={bundle}
54
+ activeLocale={locale}
55
+ defaultLocale={locale}
56
+ localeDefinitions={[]}
57
+ >
58
+ {children}
59
+ </I18nProvider>
60
+ )
61
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * SessionChangeBoundary — viewport-centred interstitial shown when the
3
+ * active login identity no longer matches the one this page was rendered
4
+ * for. Rare by design: it only appears on an account mismatch.
5
+ *
6
+ * Override handles:
7
+ * .byline-session-change — outer <main>, centres the card in the viewport
8
+ * .byline-session-change-card — the bordered card itself
9
+ * .byline-session-change-title — heading
10
+ * .byline-session-change-body — explanatory paragraph
11
+ * .byline-session-change-account — "Active account: …" line
12
+ * .byline-session-change-error — error paragraph (role="alert")
13
+ * .byline-session-change-actions — button row
14
+ */
15
+
16
+ .main,
17
+ :global(.byline-session-change) {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ min-height: 100dvh;
22
+ padding: 1.5rem;
23
+ /* Bias the card just above the geometric centre — a dialog sitting dead
24
+ centre optically reads as slightly low. The extra bottom padding lifts it
25
+ by half its own value, and being proportional it degrades gracefully on
26
+ short viewports instead of clipping the card off the top. */
27
+ padding-bottom: calc(1.5rem + 12vh);
28
+ }
29
+
30
+ .card,
31
+ :global(.byline-session-change-card) {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 0.75rem;
35
+ width: 100%;
36
+ max-width: 30rem;
37
+ padding: 1.5rem;
38
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
39
+ border-radius: var(--border-radius-md);
40
+ background-color: var(--canvas-25);
41
+ }
42
+
43
+ :is([data-theme="dark"], :global(.dark)) .card,
44
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-card) {
45
+ border-color: var(--gray-700);
46
+ background-color: var(--canvas-800);
47
+ }
48
+
49
+ .title,
50
+ :global(.byline-session-change-title) {
51
+ margin: 0;
52
+ font-size: var(--font-size-xl);
53
+ font-weight: var(--font-weight-semibold);
54
+ line-height: var(--line-height-snug);
55
+ letter-spacing: var(--letter-spacing-tight);
56
+ color: var(--gray-900);
57
+ }
58
+
59
+ :is([data-theme="dark"], :global(.dark)) .title,
60
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-title) {
61
+ color: var(--gray-100);
62
+ }
63
+
64
+ .body,
65
+ :global(.byline-session-change-body) {
66
+ margin: 0;
67
+ font-size: var(--font-size-sm);
68
+ line-height: var(--line-height-relaxed);
69
+ color: var(--gray-700);
70
+ }
71
+
72
+ :is([data-theme="dark"], :global(.dark)) .body,
73
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-body) {
74
+ color: var(--gray-400);
75
+ }
76
+
77
+ .account,
78
+ :global(.byline-session-change-account) {
79
+ margin: 0;
80
+ font-size: var(--font-size-sm);
81
+ font-weight: var(--font-weight-medium);
82
+ line-height: var(--line-height-normal);
83
+ color: var(--gray-900);
84
+ overflow-wrap: anywhere;
85
+ }
86
+
87
+ :is([data-theme="dark"], :global(.dark)) .account,
88
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-account) {
89
+ color: var(--gray-100);
90
+ }
91
+
92
+ .error,
93
+ :global(.byline-session-change-error) {
94
+ margin: 0;
95
+ font-size: var(--font-size-sm);
96
+ line-height: var(--line-height-relaxed);
97
+ color: var(--red-600);
98
+ }
99
+
100
+ :is([data-theme="dark"], :global(.dark)) .error,
101
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-error) {
102
+ color: var(--red-400);
103
+ }
104
+
105
+ .actions,
106
+ :global(.byline-session-change-actions) {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ align-items: center;
110
+ gap: 0.75rem;
111
+ margin-top: 0.25rem;
112
+ }
@@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({ current: vi.fn() }))
15
15
  vi.mock('../../server-fns/auth/current-user.js', () => ({ getCurrentAdminUser: mocks.current }))
16
16
  vi.mock('../../routes/sign-in-path.js', () => ({ getSignInRoutePath: () => '/sign-in' }))
17
17
 
18
+ import { adminTranslations } from '@byline/i18n/admin'
19
+ import { I18nProvider } from '@byline/i18n/react'
20
+
18
21
  import { acceptSession, expectedSession } from '../../integrations/session-coordination.js'
19
22
  import { SessionChangeBoundary } from './session-change-boundary.js'
20
23
 
@@ -44,12 +47,22 @@ afterEach(() => {
44
47
  container.remove()
45
48
  vi.unstubAllGlobals()
46
49
  })
50
+ // Mounted inside a real provider so the assertions below read the shipped
51
+ // English strings, and so the boundary's `OptionalI18nProvider` exercises its
52
+ // reuse path (an existing context is preserved rather than self-mounted).
47
53
  async function render() {
48
54
  await act(async () => {
49
55
  root.render(
50
- <SessionChangeBoundary user={user('late-A', 'x@example.test')}>
51
- <button type="button">Publish</button>
52
- </SessionChangeBoundary>
56
+ <I18nProvider
57
+ bundle={adminTranslations({ locales: ['en'] })}
58
+ activeLocale="en"
59
+ defaultLocale="en"
60
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
61
+ >
62
+ <SessionChangeBoundary user={user('late-A', 'x@example.test')}>
63
+ <button type="button">Publish</button>
64
+ </SessionChangeBoundary>
65
+ </I18nProvider>
53
66
  )
54
67
  })
55
68
  }
@@ -8,6 +8,10 @@
8
8
 
9
9
  import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
10
10
 
11
+ import { useTranslation } from '@byline/i18n/react'
12
+ import { Button } from '@byline/ui/react'
13
+ import cx from 'clsx'
14
+
11
15
  import {
12
16
  acceptSession,
13
17
  expectedSession,
@@ -20,6 +24,8 @@ import {
20
24
  } from '../../integrations/session-coordination.js'
21
25
  import { getSignInRoutePath } from '../../routes/sign-in-path.js'
22
26
  import { type CurrentAdminUser, getCurrentAdminUser } from '../../server-fns/auth/current-user.js'
27
+ import { OptionalI18nProvider } from './optional-i18n-provider.js'
28
+ import styles from './session-change-boundary.module.css'
23
29
 
24
30
  /** Blocks the page until the user explicitly acknowledges freshly verified login identity. */
25
31
  export function SessionChangeBoundary({
@@ -57,7 +63,7 @@ export function SessionChangeBoundary({
57
63
  setCurrent(verified)
58
64
  setError(undefined)
59
65
  })
60
- .catch(() => setError('The session is no longer available. Please sign in again.'))
66
+ .catch(() => setError('sessionChange.errors.unavailable'))
61
67
  }, [changed, mismatch, epoch])
62
68
  if (!changed && !mismatch) return children
63
69
  async function acknowledge() {
@@ -67,28 +73,83 @@ export function SessionChangeBoundary({
67
73
  // Require a second acknowledgement if the identity changed while this interstitial was open.
68
74
  if (!current || current.sessionId !== verified.sessionId) {
69
75
  setCurrent(verified)
70
- setError('The active session changed again. Review the account before continuing.')
76
+ setError('sessionChange.errors.changedAgain')
71
77
  return
72
78
  }
73
79
  acceptSession(verified.sessionId)
74
80
  window.location.reload() // Discard queued edits and old page state; never replay them.
75
81
  } catch {
76
82
  flagSessionChanged()
77
- setError('Unable to verify the active session. Please sign in again.')
83
+ setError('sessionChange.errors.unverifiable')
78
84
  } finally {
79
85
  setBusy(false)
80
86
  }
81
87
  }
82
88
  return (
83
- <main role="alertdialog" aria-modal="true" aria-labelledby="session-change-title">
84
- <h1 id="session-change-title">Your session has changed</h1>
85
- <p>Your previous work has not been retried. Check the active account before continuing.</p>
86
- {current && <p>Active account: {current.email}</p>}
87
- {error && <p role="alert">{error}</p>}
88
- <button type="button" disabled={busy || !current} onClick={acknowledge}>
89
- Continue as this account
90
- </button>
91
- <a href={`${getSignInRoutePath()}?reauthenticate=1`}>Sign in again</a>
89
+ <OptionalI18nProvider>
90
+ <SessionChangeInterstitial
91
+ current={current}
92
+ errorKey={error}
93
+ busy={busy}
94
+ onAcknowledge={acknowledge}
95
+ />
96
+ </OptionalI18nProvider>
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Presentation half. Split out so the error state can be carried as a
102
+ * translation *key* through the logic above and resolved here, inside the
103
+ * provider — the boundary itself renders outside any `<I18nProvider>` at
104
+ * both of its mount sites.
105
+ */
106
+ function SessionChangeInterstitial({
107
+ current,
108
+ errorKey,
109
+ busy,
110
+ onAcknowledge,
111
+ }: {
112
+ current?: CurrentAdminUser
113
+ errorKey?: string
114
+ busy: boolean
115
+ onAcknowledge: () => void
116
+ }) {
117
+ const { t } = useTranslation('byline-admin')
118
+ return (
119
+ <main
120
+ className={cx('byline-session-change', styles.main)}
121
+ role="alertdialog"
122
+ aria-modal="true"
123
+ aria-labelledby="session-change-title"
124
+ >
125
+ <div className={cx('byline-session-change-card', styles.card)}>
126
+ <h1 id="session-change-title" className={cx('byline-session-change-title', styles.title)}>
127
+ {t('sessionChange.title')}
128
+ </h1>
129
+ <p className={cx('byline-session-change-body', styles.body)}>{t('sessionChange.body')}</p>
130
+ {current && (
131
+ <p className={cx('byline-session-change-account', styles.account)}>
132
+ {t('sessionChange.activeAccount', { email: current.email })}
133
+ </p>
134
+ )}
135
+ {errorKey && (
136
+ <p className={cx('byline-session-change-error', styles.error)} role="alert">
137
+ {t(errorKey)}
138
+ </p>
139
+ )}
140
+ <div className={cx('byline-session-change-actions', styles.actions)}>
141
+ <Button type="button" disabled={busy || !current} onClick={onAcknowledge}>
142
+ {t('sessionChange.continue')}
143
+ </Button>
144
+ <Button
145
+ variant="outlined"
146
+ intent="noeffect"
147
+ render={<a href={`${getSignInRoutePath()}?reauthenticate=1`} />}
148
+ >
149
+ {t('sessionChange.signInAgain')}
150
+ </Button>
151
+ </div>
152
+ </div>
92
153
  </main>
93
154
  )
94
155
  }