@byline/host-tanstack-start 5.1.2 → 5.1.4
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/sign-in-page.js +76 -34
- package/dist/admin-shell/chrome/sign-in-page.module.js +6 -1
- package/dist/admin-shell/chrome/sign-in-page.test.d.ts +8 -0
- package/dist/admin-shell/chrome/sign-in-page_module.css +49 -0
- package/dist/integrations/admin-session-recovery.d.ts +10 -0
- package/dist/integrations/admin-session-recovery.js +33 -0
- package/dist/integrations/admin-session-recovery.test.d.ts +8 -0
- package/dist/integrations/session-renewal.d.ts +8 -0
- package/dist/integrations/session-renewal.js +16 -0
- package/dist/integrations/session-renewal.test.d.ts +8 -0
- package/dist/routes/admin-layout-guard.d.ts +20 -0
- package/dist/routes/admin-layout-guard.js +40 -0
- package/dist/routes/create-admin-analytics-route.js +17 -5
- package/dist/routes/create-admin-layout-route.js +25 -35
- package/dist/server-fns/auth/current-user.d.ts +18 -2
- package/dist/server-fns/auth/current-user.js +32 -15
- package/dist/server-fns/auth/index.d.ts +1 -1
- package/dist/server-fns/auth/index.js +1 -1
- package/package.json +10 -10
- package/src/admin-shell/chrome/sign-in-page.module.css +59 -0
- package/src/admin-shell/chrome/sign-in-page.test.tsx +99 -0
- package/src/admin-shell/chrome/sign-in-page.tsx +81 -25
- package/src/integrations/admin-session-recovery.test.tsx +120 -0
- package/src/integrations/admin-session-recovery.tsx +72 -0
- package/src/integrations/session-renewal.test.tsx +72 -0
- package/src/integrations/session-renewal.ts +45 -0
- package/src/routes/admin-layout-guard.test.node.ts +136 -0
- package/src/routes/admin-layout-guard.ts +91 -0
- package/src/routes/create-admin-analytics-route.tsx +23 -5
- package/src/routes/create-admin-layout-route.tsx +33 -46
- package/src/server-fns/auth/current-user.test.node.ts +85 -0
- package/src/server-fns/auth/current-user.ts +49 -19
- package/src/server-fns/auth/index.ts +2 -0
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.4",
|
|
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/
|
|
129
|
-
"@byline/
|
|
130
|
-
"@byline/
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/client": "5.1.
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/i18n": "5.1.
|
|
135
|
-
"@byline/
|
|
136
|
-
"@byline/ui": "5.1.
|
|
128
|
+
"@byline/analytics": "5.1.4",
|
|
129
|
+
"@byline/admin": "5.1.4",
|
|
130
|
+
"@byline/auth": "5.1.4",
|
|
131
|
+
"@byline/ai": "5.1.4",
|
|
132
|
+
"@byline/client": "5.1.4",
|
|
133
|
+
"@byline/core": "5.1.4",
|
|
134
|
+
"@byline/i18n": "5.1.4",
|
|
135
|
+
"@byline/analytics-agent": "5.1.4",
|
|
136
|
+
"@byline/ui": "5.1.4"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
139
139
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* .byline-sign-in-page — outer <main>
|
|
6
6
|
* .byline-sign-in-page-bar — top bar carrying the LanguageMenu
|
|
7
7
|
* .byline-sign-in-page-inner — vertically-centred card container
|
|
8
|
+
* .byline-sign-in-page-checking — activity indicator during session bootstrap
|
|
9
|
+
* .byline-sign-in-page-card — card frame for the bootstrap-error state (matches the form card)
|
|
10
|
+
* .byline-sign-in-page-alert — noscript / bootstrap-error alert spacing
|
|
11
|
+
* .byline-sign-in-page-actions — action row for the bootstrap-error state
|
|
8
12
|
*
|
|
9
13
|
* The Home link itself lives inside `SignInForm`'s action row — see
|
|
10
14
|
* `@byline/ui` `sign-in-form.module.css` for `.byline-sign-in-home-link`.
|
|
@@ -42,3 +46,58 @@
|
|
|
42
46
|
margin-top: 14vh;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
49
|
+
|
|
50
|
+
.checking,
|
|
51
|
+
:global(.byline-sign-in-page-checking) {
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
padding: 2rem 0;
|
|
56
|
+
color: inherit;
|
|
57
|
+
opacity: 0.6;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.srOnly {
|
|
61
|
+
position: absolute;
|
|
62
|
+
width: 1px;
|
|
63
|
+
height: 1px;
|
|
64
|
+
padding: 0;
|
|
65
|
+
margin: -1px;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
clip: rect(0, 0, 0, 0);
|
|
68
|
+
white-space: nowrap;
|
|
69
|
+
border: 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.card,
|
|
73
|
+
:global(.byline-sign-in-page-card) {
|
|
74
|
+
width: 100%;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@media (min-width: 40rem) {
|
|
78
|
+
.card,
|
|
79
|
+
:global(.byline-sign-in-page-card) {
|
|
80
|
+
max-width: 380px;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.alert,
|
|
85
|
+
:global(.byline-sign-in-page-alert) {
|
|
86
|
+
width: 100%;
|
|
87
|
+
margin-top: var(--spacing-12);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (min-width: 40rem) {
|
|
91
|
+
:global(noscript) > .alert,
|
|
92
|
+
:global(noscript) > :global(.byline-sign-in-page-alert) {
|
|
93
|
+
max-width: 380px;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.actions,
|
|
98
|
+
:global(.byline-sign-in-page-actions) {
|
|
99
|
+
display: flex;
|
|
100
|
+
justify-content: flex-end;
|
|
101
|
+
padding-top: var(--spacing-8);
|
|
102
|
+
margin-bottom: var(--spacing-8);
|
|
103
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
const mocks = vi.hoisted(() => ({ renew: vi.fn() }))
|
|
15
|
+
vi.mock('../../server-fns/auth/renew.js', () => ({ renewAdminSession: mocks.renew }))
|
|
16
|
+
vi.mock('../../server-fns/i18n/index.js', () => ({ setAdminLocaleFn: vi.fn() }))
|
|
17
|
+
vi.mock('../../integrations/byline-admin-services.js', () => ({ bylineAdminServices: {} }))
|
|
18
|
+
vi.mock('@byline/core', async (importOriginal) => {
|
|
19
|
+
const actual = await importOriginal<typeof import('@byline/core')>()
|
|
20
|
+
const { adminTranslations } = await import('@byline/i18n/admin')
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
getAdminConfig: () => ({
|
|
24
|
+
routes: { admin: '/admin' },
|
|
25
|
+
i18n: {
|
|
26
|
+
admin: { locales: ['en'], defaultLocale: 'en' },
|
|
27
|
+
translations: adminTranslations({ locales: ['en'] }),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
import { SignInPage } from './sign-in-page.js'
|
|
34
|
+
|
|
35
|
+
let root: Root
|
|
36
|
+
let container: HTMLDivElement
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks()
|
|
39
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
40
|
+
vi.stubGlobal('BroadcastChannel', undefined)
|
|
41
|
+
window.sessionStorage.clear()
|
|
42
|
+
container = document.createElement('div')
|
|
43
|
+
document.body.appendChild(container)
|
|
44
|
+
root = createRoot(container)
|
|
45
|
+
})
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
act(() => root.unmount())
|
|
48
|
+
container.remove()
|
|
49
|
+
vi.unstubAllGlobals()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
async function render() {
|
|
53
|
+
await act(async () => {
|
|
54
|
+
root.render(<SignInPage redirectTo="/admin" activeLocale="en" reauthenticate={false} />)
|
|
55
|
+
})
|
|
56
|
+
// The bootstrap promise settles through `.catch`/`.finally` after the first
|
|
57
|
+
// commit; yield one macrotask inside `act` so that state update is flushed.
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('SignInPage bootstrap states', () => {
|
|
64
|
+
it('renders the bootstrap error in the kit card with a kit button', async () => {
|
|
65
|
+
mocks.renew.mockRejectedValue(new Error('network'))
|
|
66
|
+
await render()
|
|
67
|
+
const alert = container.querySelector('[role="alert"]')
|
|
68
|
+
expect(alert).not.toBeNull()
|
|
69
|
+
expect(alert?.classList.contains('byline-card')).toBe(true)
|
|
70
|
+
expect(alert?.querySelector('.byline-alert-danger')?.textContent).toContain(
|
|
71
|
+
'Unable to check your session. Please try again.'
|
|
72
|
+
)
|
|
73
|
+
// The alert itself carries no dismiss button; the only button is the retry action.
|
|
74
|
+
expect(alert?.querySelectorAll('button').length).toBe(1)
|
|
75
|
+
const button = alert?.querySelector('.byline-sign-in-page-actions button')
|
|
76
|
+
expect(button?.classList.contains('byline-button')).toBe(true)
|
|
77
|
+
expect(button?.textContent).toBe('Try again')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls through to the sign-in form on an ordinary auth outcome', async () => {
|
|
81
|
+
mocks.renew.mockRejectedValue(Object.assign(new Error('none'), { code: 'ERR_UNAUTHENTICATED' }))
|
|
82
|
+
await render()
|
|
83
|
+
expect(container.querySelector('[role="alert"]')).toBeNull()
|
|
84
|
+
expect(container.querySelector('form.byline-sign-in-form')).not.toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Runs last: `renewSingleFlight` keeps the in-flight promise at module level,
|
|
88
|
+
// and a never-settling renewal would block every later case in this file.
|
|
89
|
+
it('shows a wordless activity indicator while the bootstrap renewal is pending', async () => {
|
|
90
|
+
mocks.renew.mockReturnValue(new Promise(() => {}))
|
|
91
|
+
await render()
|
|
92
|
+
const status = container.querySelector('[role="status"]')
|
|
93
|
+
expect(status).not.toBeNull()
|
|
94
|
+
expect(status?.querySelector('.byline-loader-ring')).not.toBeNull()
|
|
95
|
+
// The only text is the visually hidden label; nothing advertises a session check.
|
|
96
|
+
expect(status?.textContent).toBe('Loading…')
|
|
97
|
+
expect(container.textContent).not.toContain('session')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -12,19 +12,14 @@ import { SignInForm } from '@byline/admin/auth/components/sign-in-form'
|
|
|
12
12
|
import { BylineAdminServicesProvider } from '@byline/admin/services'
|
|
13
13
|
import { getAdminConfig } from '@byline/core'
|
|
14
14
|
import type { LocaleCode } from '@byline/i18n'
|
|
15
|
-
import { I18nProvider, LanguageMenu } from '@byline/i18n/react'
|
|
15
|
+
import { I18nProvider, LanguageMenu, useTranslation } from '@byline/i18n/react'
|
|
16
|
+
import { Alert, Button, Card, LoaderRing } from '@byline/ui/react'
|
|
16
17
|
import cx from 'clsx'
|
|
17
18
|
|
|
18
19
|
import { buildLocaleDefinitions } from '../../i18n/locale-definitions.js'
|
|
19
20
|
import { bylineAdminServices } from '../../integrations/byline-admin-services.js'
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
expectedSession,
|
|
23
|
-
flagSessionChanged,
|
|
24
|
-
observeSession,
|
|
25
|
-
renewSingleFlight,
|
|
26
|
-
} from '../../integrations/session-coordination.js'
|
|
27
|
-
import { renewAdminSession } from '../../server-fns/auth/renew.js'
|
|
21
|
+
import { flagSessionChanged } from '../../integrations/session-coordination.js'
|
|
22
|
+
import { renewExpectedSession } from '../../integrations/session-renewal.js'
|
|
28
23
|
import { setAdminLocaleFn } from '../../server-fns/i18n/index.js'
|
|
29
24
|
import { SessionChangeBoundary } from './session-change-boundary.js'
|
|
30
25
|
import styles from './sign-in-page.module.css'
|
|
@@ -67,14 +62,7 @@ export function SignInPage({
|
|
|
67
62
|
useEffect(() => {
|
|
68
63
|
if (reauthenticate) return
|
|
69
64
|
let active = true
|
|
70
|
-
void
|
|
71
|
-
coordinateAuthAction(async () => {
|
|
72
|
-
const result = await renewAdminSession({
|
|
73
|
-
data: { expectedSessionId: expectedSession() ?? undefined },
|
|
74
|
-
})
|
|
75
|
-
observeSession(result.sessionId)
|
|
76
|
-
})
|
|
77
|
-
)
|
|
65
|
+
void renewExpectedSession()
|
|
78
66
|
.then(() => {
|
|
79
67
|
if (active) window.location.replace(redirectTo)
|
|
80
68
|
})
|
|
@@ -113,20 +101,15 @@ export function SignInPage({
|
|
|
113
101
|
>
|
|
114
102
|
<BylineAdminServicesProvider services={bylineAdminServices}>
|
|
115
103
|
<main className={cx('byline-sign-in-page', styles.main)}>
|
|
116
|
-
<noscript>JavaScript is required to sign in or renew your admin session.</noscript>
|
|
117
104
|
<div className={cx('byline-sign-in-page-bar', styles.bar)}>
|
|
118
105
|
<LanguageMenu />
|
|
119
106
|
</div>
|
|
120
107
|
<div className={cx('byline-sign-in-page-inner', styles.inner)}>
|
|
108
|
+
<NoScriptNotice />
|
|
121
109
|
{bootstrapError ? (
|
|
122
|
-
<
|
|
123
|
-
<p>Unable to check your session. Please try again.</p>
|
|
124
|
-
<button type="button" onClick={() => window.location.reload()}>
|
|
125
|
-
Try again
|
|
126
|
-
</button>
|
|
127
|
-
</div>
|
|
110
|
+
<SessionCheckError />
|
|
128
111
|
) : checking ? (
|
|
129
|
-
<
|
|
112
|
+
<SessionCheckIndicator />
|
|
130
113
|
) : (
|
|
131
114
|
<SignInForm redirectTo={redirectTo} homeUrl={homeUrl} />
|
|
132
115
|
)}
|
|
@@ -137,3 +120,76 @@ export function SignInPage({
|
|
|
137
120
|
)
|
|
138
121
|
return reauthenticate ? content : <SessionChangeBoundary>{content}</SessionChangeBoundary>
|
|
139
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Activity indicator shown while the bootstrap renewal runs. Deliberately
|
|
126
|
+
* wordless on screen; the visually hidden label keeps it announced for
|
|
127
|
+
* assistive technology. The ring inherits `currentColor`, so it follows
|
|
128
|
+
* the host theme in light and dark mode without a hard-coded colour.
|
|
129
|
+
*/
|
|
130
|
+
function SessionCheckIndicator() {
|
|
131
|
+
const { t } = useTranslation('byline-admin')
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
role="status"
|
|
135
|
+
aria-live="polite"
|
|
136
|
+
className={cx('byline-sign-in-page-checking', styles.checking)}
|
|
137
|
+
>
|
|
138
|
+
<LoaderRing size={32} aria-hidden="true" />
|
|
139
|
+
<span className={styles.srOnly}>{t('common.loading')}</span>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Shown only when JavaScript is unavailable. React renders `<noscript>`
|
|
146
|
+
* children as static markup on the server and skips them on the client,
|
|
147
|
+
* so the kit `Alert` here is SSR-only chrome that never hydrates.
|
|
148
|
+
*/
|
|
149
|
+
function NoScriptNotice() {
|
|
150
|
+
const { t } = useTranslation('byline-admin')
|
|
151
|
+
return (
|
|
152
|
+
<noscript>
|
|
153
|
+
<Alert
|
|
154
|
+
intent="warning"
|
|
155
|
+
close={false}
|
|
156
|
+
className={cx('byline-sign-in-page-alert', styles.alert)}
|
|
157
|
+
>
|
|
158
|
+
{t('auth.signIn.noscript')}
|
|
159
|
+
</Alert>
|
|
160
|
+
</noscript>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Bootstrap renewal hit a service or network error, not an auth outcome.
|
|
166
|
+
* Presented in the same card frame as the form so the three page states
|
|
167
|
+
* share one visual footprint. Retry is a full reload: it re-runs the
|
|
168
|
+
* bootstrap without resubmitting anything.
|
|
169
|
+
*/
|
|
170
|
+
function SessionCheckError() {
|
|
171
|
+
const { t } = useTranslation('byline-admin')
|
|
172
|
+
return (
|
|
173
|
+
<Card role="alert" className={cx('byline-sign-in-page-card', styles.card)}>
|
|
174
|
+
<Card.Header>
|
|
175
|
+
<Card.Title>
|
|
176
|
+
<h2>{t('auth.signIn.title')}</h2>
|
|
177
|
+
</Card.Title>
|
|
178
|
+
<Alert
|
|
179
|
+
intent="danger"
|
|
180
|
+
close={false}
|
|
181
|
+
className={cx('byline-sign-in-page-alert', styles.alert)}
|
|
182
|
+
>
|
|
183
|
+
{t('auth.signIn.errors.bootstrap')}
|
|
184
|
+
</Alert>
|
|
185
|
+
</Card.Header>
|
|
186
|
+
<Card.Content>
|
|
187
|
+
<div className={cx('byline-sign-in-page-actions', styles.actions)}>
|
|
188
|
+
<Button type="button" onClick={() => window.location.reload()}>
|
|
189
|
+
{t('common.actions.tryAgain')}
|
|
190
|
+
</Button>
|
|
191
|
+
</div>
|
|
192
|
+
</Card.Content>
|
|
193
|
+
</Card>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
const mocks = vi.hoisted(() => {
|
|
15
|
+
const invalidate = vi.fn()
|
|
16
|
+
return { renew: vi.fn(), invalidate, router: { invalidate }, href: '/en/news' }
|
|
17
|
+
})
|
|
18
|
+
vi.mock('./session-renewal.js', () => ({ renewExpectedSession: mocks.renew }))
|
|
19
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
20
|
+
// A real router instance is stable across renders; location changes on navigation.
|
|
21
|
+
useRouter: () => mocks.router,
|
|
22
|
+
useRouterState: ({ select }: { select: (state: unknown) => unknown }) =>
|
|
23
|
+
select({ location: { href: mocks.href } }),
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
import { AdminSessionRecovery } from './admin-session-recovery.js'
|
|
27
|
+
|
|
28
|
+
let root: Root
|
|
29
|
+
let container: HTMLDivElement
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks()
|
|
32
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
33
|
+
mocks.href = '/en/news'
|
|
34
|
+
mocks.invalidate.mockResolvedValue(undefined)
|
|
35
|
+
container = document.createElement('div')
|
|
36
|
+
document.body.appendChild(container)
|
|
37
|
+
root = createRoot(container)
|
|
38
|
+
})
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
act(() => root.unmount())
|
|
41
|
+
container.remove()
|
|
42
|
+
vi.unstubAllGlobals()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
async function render(renewable: boolean) {
|
|
46
|
+
await act(async () => {
|
|
47
|
+
root.render(<AdminSessionRecovery renewable={renewable} />)
|
|
48
|
+
})
|
|
49
|
+
await act(async () => {
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('AdminSessionRecovery', () => {
|
|
55
|
+
it('renders nothing and does not renew when the session is not renewable', async () => {
|
|
56
|
+
await render(false)
|
|
57
|
+
expect(container.innerHTML).toBe('')
|
|
58
|
+
expect(mocks.renew).not.toHaveBeenCalled()
|
|
59
|
+
expect(mocks.invalidate).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renews and re-runs the route loaders when the session is renewable', async () => {
|
|
63
|
+
mocks.renew.mockResolvedValue(undefined)
|
|
64
|
+
await render(true)
|
|
65
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(mocks.invalidate).toHaveBeenCalledTimes(1)
|
|
67
|
+
expect(container.innerHTML).toBe('')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('does not re-run loaders when renewal fails for a service reason', async () => {
|
|
71
|
+
mocks.renew.mockRejectedValue(new Error('network'))
|
|
72
|
+
await render(true)
|
|
73
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
74
|
+
expect(mocks.invalidate).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renews only once while mounted even across re-renders', async () => {
|
|
78
|
+
mocks.renew.mockResolvedValue(undefined)
|
|
79
|
+
await render(true)
|
|
80
|
+
await render(true)
|
|
81
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('recovers again on a later expiry cycle while the layout stays mounted', async () => {
|
|
85
|
+
mocks.renew.mockResolvedValue(undefined)
|
|
86
|
+
await render(true)
|
|
87
|
+
// The loader re-ran after renewal and reported a live session...
|
|
88
|
+
await render(false)
|
|
89
|
+
// ...and a later navigation finds the access credential expired again.
|
|
90
|
+
await render(true)
|
|
91
|
+
expect(mocks.renew).toHaveBeenCalledTimes(2)
|
|
92
|
+
expect(mocks.invalidate).toHaveBeenCalledTimes(2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('retries on a later navigation after a service failure', async () => {
|
|
96
|
+
mocks.renew.mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce(undefined)
|
|
97
|
+
await render(true)
|
|
98
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
99
|
+
mocks.href = '/en/news/second-story'
|
|
100
|
+
await render(true)
|
|
101
|
+
expect(mocks.renew).toHaveBeenCalledTimes(2)
|
|
102
|
+
expect(mocks.invalidate).toHaveBeenCalledTimes(1)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// Runs last: a terminal outcome suppresses further attempts for the life of
|
|
106
|
+
// the page (module state), so later cases in this file would see no renewal.
|
|
107
|
+
it('stops retrying after a terminal auth outcome until the page reloads', async () => {
|
|
108
|
+
mocks.renew.mockRejectedValue(
|
|
109
|
+
Object.assign(new Error('revoked'), { code: 'ERR_REVOKED_TOKEN' })
|
|
110
|
+
)
|
|
111
|
+
await render(true)
|
|
112
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
113
|
+
act(() => root.unmount())
|
|
114
|
+
root = createRoot(container)
|
|
115
|
+
mocks.href = '/en/news/after-revocation'
|
|
116
|
+
await render(true)
|
|
117
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
118
|
+
expect(mocks.invalidate).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Public-page session recovery.
|
|
11
|
+
*
|
|
12
|
+
* Public reads are verification-only and never rotate credentials, so a
|
|
13
|
+
* signed-in editor whose access credential has expired renders a public page
|
|
14
|
+
* as an anonymous visitor: the admin bar hides and preview mode drops. Mount
|
|
15
|
+
* this component in the public layout with the `renewable` hint from
|
|
16
|
+
* `getCurrentAdminSessionSoft`. When the hint is set it runs the coordinated
|
|
17
|
+
* browser renewal (same expected-login check as admin navigation) and then
|
|
18
|
+
* re-runs the route loaders, so the admin bar and any draft-aware reads on
|
|
19
|
+
* the page re-evaluate against the renewed session.
|
|
20
|
+
*
|
|
21
|
+
* It renders nothing. Content already on screen stays as rendered until the
|
|
22
|
+
* loaders re-run; the renewal never retroactively changes a response that was
|
|
23
|
+
* produced without preview authorisation.
|
|
24
|
+
*
|
|
25
|
+
* The public layout stays mounted across navigations, so recovery must work
|
|
26
|
+
* for every expiry cycle, not just the first: one attempt runs per renewable
|
|
27
|
+
* hint per location, never overlapping. After a successful renewal the loader
|
|
28
|
+
* re-runs and clears the hint; the next expiry sets it again and recovery
|
|
29
|
+
* runs again. After a service or network failure the hint persists, and the
|
|
30
|
+
* next navigation (a new location) tries again.
|
|
31
|
+
*
|
|
32
|
+
* A terminal auth outcome (revoked, disabled, unknown) suppresses further
|
|
33
|
+
* attempts for the life of the page: the hint would otherwise repeat on every
|
|
34
|
+
* navigation while the stale refresh cookie remains.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { useEffect, useRef } from 'react'
|
|
38
|
+
import { useRouter, useRouterState } from '@tanstack/react-router'
|
|
39
|
+
|
|
40
|
+
import { renewExpectedSession } from './session-renewal.js'
|
|
41
|
+
|
|
42
|
+
const TERMINAL_CODES = new Set([
|
|
43
|
+
'ERR_UNAUTHENTICATED',
|
|
44
|
+
'ERR_INVALID_TOKEN',
|
|
45
|
+
'ERR_REVOKED_TOKEN',
|
|
46
|
+
'ERR_ACCOUNT_DISABLED',
|
|
47
|
+
'ERR_SESSION_CHANGED',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
let suppressed = false
|
|
51
|
+
|
|
52
|
+
export function AdminSessionRecovery({ renewable }: { renewable: boolean }) {
|
|
53
|
+
const router = useRouter()
|
|
54
|
+
const href = useRouterState({ select: (state) => state.location.href })
|
|
55
|
+
const inFlight = useRef(false)
|
|
56
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `href` is not read inside the effect; it re-arms recovery on navigation so a service failure can be retried later.
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!renewable || inFlight.current || suppressed) return
|
|
59
|
+
inFlight.current = true
|
|
60
|
+
// `router.invalidate()` is safe after unmount, so no cleanup guard: a
|
|
61
|
+
// renewal that completes during a layout remount still refreshes data.
|
|
62
|
+
renewExpectedSession()
|
|
63
|
+
.then(() => router.invalidate())
|
|
64
|
+
.catch((error) => {
|
|
65
|
+
if (TERMINAL_CODES.has((error as { code?: string })?.code ?? '')) suppressed = true
|
|
66
|
+
})
|
|
67
|
+
.finally(() => {
|
|
68
|
+
inFlight.current = false
|
|
69
|
+
})
|
|
70
|
+
}, [renewable, href, router])
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
const mocks = vi.hoisted(() => ({ renew: vi.fn() }))
|
|
12
|
+
vi.mock('../server-fns/auth/renew.js', () => ({ renewAdminSession: mocks.renew }))
|
|
13
|
+
|
|
14
|
+
import { acceptSession, expectedSession, sessionIsChanged } from './session-coordination.js'
|
|
15
|
+
import { renewExpectedSession } from './session-renewal.js'
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks()
|
|
19
|
+
vi.stubGlobal('BroadcastChannel', undefined)
|
|
20
|
+
window.sessionStorage.clear()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('renewExpectedSession', () => {
|
|
24
|
+
// Runs first: the coordinator caches the expected login at module level, so
|
|
25
|
+
// only the initial case in this file observes a page with no expected login.
|
|
26
|
+
it('adopts the verified login when the page had no expected login yet', async () => {
|
|
27
|
+
mocks.renew.mockResolvedValue({ sessionId: 'login-2' })
|
|
28
|
+
await renewExpectedSession()
|
|
29
|
+
expect(mocks.renew).toHaveBeenCalledWith({ data: { expectedSessionId: undefined } })
|
|
30
|
+
expect(expectedSession()).toBe('login-2')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('sends the stored expected login and keeps it when renewal returns the same login', async () => {
|
|
34
|
+
acceptSession('login-1')
|
|
35
|
+
mocks.renew.mockResolvedValue({ sessionId: 'login-1' })
|
|
36
|
+
await renewExpectedSession()
|
|
37
|
+
expect(mocks.renew).toHaveBeenCalledWith({ data: { expectedSessionId: 'login-1' } })
|
|
38
|
+
expect(expectedSession()).toBe('login-1')
|
|
39
|
+
expect(sessionIsChanged()).toBe(false)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('flags a session change and rethrows when renewal reports one', async () => {
|
|
43
|
+
acceptSession('login-1')
|
|
44
|
+
mocks.renew.mockRejectedValue(
|
|
45
|
+
Object.assign(new Error('changed'), { code: 'ERR_SESSION_CHANGED' })
|
|
46
|
+
)
|
|
47
|
+
await expect(renewExpectedSession()).rejects.toMatchObject({ code: 'ERR_SESSION_CHANGED' })
|
|
48
|
+
expect(sessionIsChanged()).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('flags a session change when renewal returns a different login', async () => {
|
|
52
|
+
acceptSession('login-1')
|
|
53
|
+
mocks.renew.mockResolvedValue({ sessionId: 'login-9' })
|
|
54
|
+
await expect(renewExpectedSession()).rejects.toThrow()
|
|
55
|
+
expect(sessionIsChanged()).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('coalesces concurrent callers into one renewal request', async () => {
|
|
59
|
+
acceptSession('login-1')
|
|
60
|
+
let release!: () => void
|
|
61
|
+
mocks.renew.mockReturnValue(
|
|
62
|
+
new Promise((resolve) => {
|
|
63
|
+
release = () => resolve({ sessionId: 'login-1' })
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
const first = renewExpectedSession()
|
|
67
|
+
const second = renewExpectedSession()
|
|
68
|
+
release()
|
|
69
|
+
await Promise.all([first, second])
|
|
70
|
+
expect(mocks.renew).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Browser-side session renewal shared by every navigation-time caller: the
|
|
11
|
+
* admin layout guard (client-side navigation after access expiry) and the
|
|
12
|
+
* sign-in page bootstrap (server-rendered navigation after access expiry).
|
|
13
|
+
*
|
|
14
|
+
* Runs the CSRF-protected renewal server fn once per page (same-tab
|
|
15
|
+
* single-flight), inside the cross-tab auth-action queue, carrying the page's
|
|
16
|
+
* expected login so renewal can never silently adopt another tab's account.
|
|
17
|
+
* A reported or observed login change flags the session-change interstitial
|
|
18
|
+
* and rejects; callers must not treat that as a retryable outcome.
|
|
19
|
+
*
|
|
20
|
+
* Business-operation renewal lives in `session-middleware.ts`; it captures a
|
|
21
|
+
* per-operation snapshot and resends, which this navigation helper does not.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { renewAdminSession } from '../server-fns/auth/renew.js'
|
|
25
|
+
import {
|
|
26
|
+
coordinateAuthAction,
|
|
27
|
+
expectedSession,
|
|
28
|
+
flagSessionChanged,
|
|
29
|
+
observeSession,
|
|
30
|
+
renewSingleFlight,
|
|
31
|
+
} from './session-coordination.js'
|
|
32
|
+
|
|
33
|
+
export function renewExpectedSession(): Promise<void> {
|
|
34
|
+
return renewSingleFlight(() =>
|
|
35
|
+
coordinateAuthAction(async () => {
|
|
36
|
+
const result = await renewAdminSession({
|
|
37
|
+
data: { expectedSessionId: expectedSession() ?? undefined },
|
|
38
|
+
}).catch((error) => {
|
|
39
|
+
if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
|
|
40
|
+
throw error
|
|
41
|
+
})
|
|
42
|
+
observeSession(result.sessionId)
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
}
|