@byline/host-tanstack-start 5.1.2 → 5.1.3

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.
Files changed (31) hide show
  1. package/dist/admin-shell/chrome/sign-in-page.js +76 -34
  2. package/dist/admin-shell/chrome/sign-in-page.module.js +6 -1
  3. package/dist/admin-shell/chrome/sign-in-page.test.d.ts +8 -0
  4. package/dist/admin-shell/chrome/sign-in-page_module.css +49 -0
  5. package/dist/integrations/admin-session-recovery.d.ts +10 -0
  6. package/dist/integrations/admin-session-recovery.js +33 -0
  7. package/dist/integrations/admin-session-recovery.test.d.ts +8 -0
  8. package/dist/integrations/session-renewal.d.ts +8 -0
  9. package/dist/integrations/session-renewal.js +16 -0
  10. package/dist/integrations/session-renewal.test.d.ts +8 -0
  11. package/dist/routes/admin-layout-guard.d.ts +20 -0
  12. package/dist/routes/admin-layout-guard.js +40 -0
  13. package/dist/routes/create-admin-layout-route.js +25 -35
  14. package/dist/server-fns/auth/current-user.d.ts +18 -2
  15. package/dist/server-fns/auth/current-user.js +32 -15
  16. package/dist/server-fns/auth/index.d.ts +1 -1
  17. package/dist/server-fns/auth/index.js +1 -1
  18. package/package.json +10 -10
  19. package/src/admin-shell/chrome/sign-in-page.module.css +59 -0
  20. package/src/admin-shell/chrome/sign-in-page.test.tsx +99 -0
  21. package/src/admin-shell/chrome/sign-in-page.tsx +81 -25
  22. package/src/integrations/admin-session-recovery.test.tsx +120 -0
  23. package/src/integrations/admin-session-recovery.tsx +72 -0
  24. package/src/integrations/session-renewal.test.tsx +72 -0
  25. package/src/integrations/session-renewal.ts +45 -0
  26. package/src/routes/admin-layout-guard.test.node.ts +136 -0
  27. package/src/routes/admin-layout-guard.ts +91 -0
  28. package/src/routes/create-admin-layout-route.tsx +33 -46
  29. package/src/server-fns/auth/current-user.test.node.ts +85 -0
  30. package/src/server-fns/auth/current-user.ts +49 -19
  31. package/src/server-fns/auth/index.ts +2 -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
- coordinateAuthAction,
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 renewSingleFlight(() =>
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
- <div role="alert">
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
- <p role="status">Checking your session…</p>
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
+ }