@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.
- package/dist/admin-shell/chrome/optional-i18n-provider.d.ts +29 -0
- package/dist/admin-shell/chrome/optional-i18n-provider.js +24 -0
- package/dist/admin-shell/chrome/session-change-boundary.js +65 -33
- package/dist/admin-shell/chrome/session-change-boundary.module.js +11 -0
- package/dist/admin-shell/chrome/session-change-boundary_module.css +81 -0
- package/package.json +10 -10
- package/src/admin-shell/chrome/optional-i18n-provider.tsx +61 -0
- package/src/admin-shell/chrome/session-change-boundary.module.css +112 -0
- package/src/admin-shell/chrome/session-change-boundary.test.tsx +16 -3
- package/src/admin-shell/chrome/session-change-boundary.tsx +73 -12
|
@@ -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('
|
|
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('
|
|
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('
|
|
52
|
+
setError('sessionChange.errors.unverifiable');
|
|
48
53
|
} finally{
|
|
49
54
|
setBusy(false);
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
|
-
return /*#__PURE__*/
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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.
|
|
129
|
-
"@byline/
|
|
130
|
-
"@byline/
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/core": "5.1.
|
|
135
|
-
"@byline/i18n": "5.1.
|
|
136
|
-
"@byline/ui": "5.1.
|
|
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
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
83
|
+
setError('sessionChange.errors.unverifiable')
|
|
78
84
|
} finally {
|
|
79
85
|
setBusy(false)
|
|
80
86
|
}
|
|
81
87
|
}
|
|
82
88
|
return (
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
}
|