@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.
- 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-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-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
|
@@ -0,0 +1,136 @@
|
|
|
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 { isRedirect } from '@tanstack/react-router'
|
|
10
|
+
|
|
11
|
+
import { ERR_ACCESS_EXPIRED, ERR_REVOKED_TOKEN, ERR_UNAUTHENTICATED } from '@byline/auth'
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import { loadAdminLayoutContext } from './admin-layout-guard.js'
|
|
15
|
+
|
|
16
|
+
const context = { user: { sessionId: 'login-1' } }
|
|
17
|
+
const signInPath = '/sign-in'
|
|
18
|
+
const href = '/admin/collections/pages?page=2'
|
|
19
|
+
|
|
20
|
+
function deps(overrides: Partial<Parameters<typeof loadAdminLayoutContext>[0]> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
load: vi.fn().mockResolvedValue(context),
|
|
23
|
+
renewSession: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
isBrowser: true,
|
|
25
|
+
sessionChanged: () => false,
|
|
26
|
+
signInPath,
|
|
27
|
+
href,
|
|
28
|
+
...overrides,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function expectSignInRedirect(promise: Promise<unknown>) {
|
|
33
|
+
const error = await promise.then(
|
|
34
|
+
() => undefined,
|
|
35
|
+
(thrown: unknown) => thrown
|
|
36
|
+
)
|
|
37
|
+
expect(isRedirect(error)).toBe(true)
|
|
38
|
+
const options = (error as { options: { to?: string; search?: Record<string, unknown> } }).options
|
|
39
|
+
expect(options.to).toBe(signInPath)
|
|
40
|
+
expect(options.search).toEqual({ callbackUrl: href })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('loadAdminLayoutContext', () => {
|
|
44
|
+
it('returns the loaded context when the access credential verifies', async () => {
|
|
45
|
+
const d = deps()
|
|
46
|
+
await expect(loadAdminLayoutContext(d)).resolves.toBe(context)
|
|
47
|
+
expect(d.renewSession).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renews in the browser after access expiry and retries the load once', async () => {
|
|
51
|
+
const d = deps({
|
|
52
|
+
load: vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockRejectedValueOnce(ERR_ACCESS_EXPIRED({ message: 'expired' }))
|
|
55
|
+
.mockResolvedValueOnce(context),
|
|
56
|
+
})
|
|
57
|
+
await expect(loadAdminLayoutContext(d)).resolves.toBe(context)
|
|
58
|
+
expect(d.renewSession).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(d.load).toHaveBeenCalledTimes(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('redirects to sign-in on the server after access expiry without renewing', async () => {
|
|
63
|
+
const d = deps({
|
|
64
|
+
isBrowser: false,
|
|
65
|
+
load: vi.fn().mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' })),
|
|
66
|
+
})
|
|
67
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
68
|
+
expect(d.renewSession).not.toHaveBeenCalled()
|
|
69
|
+
expect(d.load).toHaveBeenCalledTimes(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('redirects to sign-in when browser renewal reports a terminal outcome', async () => {
|
|
73
|
+
const d = deps({
|
|
74
|
+
load: vi.fn().mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' })),
|
|
75
|
+
renewSession: vi.fn().mockRejectedValue(ERR_REVOKED_TOKEN({ message: 'revoked' })),
|
|
76
|
+
})
|
|
77
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
78
|
+
expect(d.load).toHaveBeenCalledTimes(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('redirects to sign-in when browser renewal fails for a non-auth reason', async () => {
|
|
82
|
+
const d = deps({
|
|
83
|
+
load: vi.fn().mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' })),
|
|
84
|
+
renewSession: vi.fn().mockRejectedValue(new Error('network')),
|
|
85
|
+
})
|
|
86
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
87
|
+
expect(d.load).toHaveBeenCalledTimes(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('propagates a non-auth failure from the retried load after a successful renewal', async () => {
|
|
91
|
+
const failure = new Error('analytics runtime unavailable')
|
|
92
|
+
const d = deps({
|
|
93
|
+
load: vi
|
|
94
|
+
.fn()
|
|
95
|
+
.mockRejectedValueOnce(ERR_ACCESS_EXPIRED({ message: 'expired' }))
|
|
96
|
+
.mockRejectedValueOnce(failure),
|
|
97
|
+
})
|
|
98
|
+
await expect(loadAdminLayoutContext(d)).rejects.toBe(failure)
|
|
99
|
+
expect(d.renewSession).toHaveBeenCalledTimes(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('redirects to sign-in when the retried load still reports an auth error', async () => {
|
|
103
|
+
const d = deps({
|
|
104
|
+
load: vi
|
|
105
|
+
.fn()
|
|
106
|
+
.mockRejectedValueOnce(ERR_ACCESS_EXPIRED({ message: 'expired' }))
|
|
107
|
+
.mockRejectedValueOnce(ERR_UNAUTHENTICATED({ message: 'no session' })),
|
|
108
|
+
})
|
|
109
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
110
|
+
expect(d.renewSession).toHaveBeenCalledTimes(1)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('redirects to sign-in without renewing when no session is present', async () => {
|
|
114
|
+
const d = deps({
|
|
115
|
+
load: vi.fn().mockRejectedValue(ERR_UNAUTHENTICATED({ message: 'no session' })),
|
|
116
|
+
})
|
|
117
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
118
|
+
expect(d.renewSession).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('redirects without renewing when the page already flagged a session change', async () => {
|
|
122
|
+
const d = deps({
|
|
123
|
+
sessionChanged: () => true,
|
|
124
|
+
load: vi.fn().mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' })),
|
|
125
|
+
})
|
|
126
|
+
await expectSignInRedirect(loadAdminLayoutContext(d))
|
|
127
|
+
expect(d.renewSession).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('rethrows non-auth load failures unchanged', async () => {
|
|
131
|
+
const failure = new Error('database unavailable')
|
|
132
|
+
const d = deps({ load: vi.fn().mockRejectedValue(failure) })
|
|
133
|
+
await expect(loadAdminLayoutContext(d)).rejects.toBe(failure)
|
|
134
|
+
expect(d.renewSession).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
* Admin layout guard — decides between loading the authenticated layout
|
|
11
|
+
* context, renewing an expired session in the browser, and redirecting to
|
|
12
|
+
* sign-in.
|
|
13
|
+
*
|
|
14
|
+
* Renewal is explicit and browser-driven, so the two render contexts behave
|
|
15
|
+
* differently on `ERR_ACCESS_EXPIRED`:
|
|
16
|
+
*
|
|
17
|
+
* - **Browser navigation** runs the coordinated renewal right here and
|
|
18
|
+
* retries the load once. The user stays on the requested URL and sees
|
|
19
|
+
* only the ordinary route pending state.
|
|
20
|
+
* - **Server render** cannot rotate credentials (SSR never holds the
|
|
21
|
+
* browser lock), and a server-side `beforeLoad` error would surface as
|
|
22
|
+
* an HTTP 500, so the server redirects to the sign-in route. That route's
|
|
23
|
+
* bootstrap performs the same renewal and returns to `callbackUrl`.
|
|
24
|
+
*
|
|
25
|
+
* Any renewal failure in the browser falls back to the same sign-in redirect:
|
|
26
|
+
* the sign-in bootstrap retries renewal once more and owns the terminal and
|
|
27
|
+
* service-failure presentation, so this guard adds no second error surface.
|
|
28
|
+
* Once renewal has succeeded, the retried load is treated like the initial
|
|
29
|
+
* one: auth outcomes redirect, everything else propagates.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { redirect } from '@tanstack/react-router'
|
|
33
|
+
|
|
34
|
+
const AUTH_REDIRECT_CODES = new Set([
|
|
35
|
+
'ERR_UNAUTHENTICATED',
|
|
36
|
+
'ERR_ACCESS_EXPIRED',
|
|
37
|
+
'ERR_INVALID_TOKEN',
|
|
38
|
+
'ERR_REVOKED_TOKEN',
|
|
39
|
+
'ERR_ACCOUNT_DISABLED',
|
|
40
|
+
'ERR_SESSION_CHANGED',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
export interface AdminLayoutGuardDeps<T> {
|
|
44
|
+
/** Load the full layout context; expected to verify the access credential first. */
|
|
45
|
+
load: () => Promise<T>
|
|
46
|
+
/** Coordinated browser renewal. Resolves on success and rejects on any failure. */
|
|
47
|
+
renewSession: () => Promise<void>
|
|
48
|
+
isBrowser: boolean
|
|
49
|
+
/** True when the page has already flagged a session change awaiting acknowledgement. */
|
|
50
|
+
sessionChanged: () => boolean
|
|
51
|
+
signInPath: string
|
|
52
|
+
/** The requested location, preserved as `callbackUrl` on redirect. */
|
|
53
|
+
href: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadAdminLayoutContext<T>(deps: AdminLayoutGuardDeps<T>): Promise<T> {
|
|
57
|
+
const toSignIn = () =>
|
|
58
|
+
redirect({
|
|
59
|
+
to: deps.signInPath as never,
|
|
60
|
+
search: { callbackUrl: deps.href } as never,
|
|
61
|
+
})
|
|
62
|
+
try {
|
|
63
|
+
return await deps.load()
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const code = errorCode(error)
|
|
66
|
+
const changed = deps.isBrowser && deps.sessionChanged()
|
|
67
|
+
if (!changed && !AUTH_REDIRECT_CODES.has(code)) throw error
|
|
68
|
+
if (changed || !deps.isBrowser || code !== 'ERR_ACCESS_EXPIRED') throw toSignIn()
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await deps.renewSession()
|
|
72
|
+
} catch {
|
|
73
|
+
// Terminal auth outcomes and service failures during renewal both hand
|
|
74
|
+
// over to the sign-in bootstrap, which owns retry and the terminal
|
|
75
|
+
// presentation.
|
|
76
|
+
throw toSignIn()
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return await deps.load()
|
|
80
|
+
} catch (error) {
|
|
81
|
+
// The session is renewed at this point. Only an auth outcome sends the
|
|
82
|
+
// user to sign-in; any other layout dependency failure reaches the
|
|
83
|
+
// route's error handling exactly as it would on the initial load.
|
|
84
|
+
if (AUTH_REDIRECT_CODES.has(errorCode(error))) throw toSignIn()
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function errorCode(error: unknown): string {
|
|
90
|
+
return (error as { code?: unknown })?.code as string
|
|
91
|
+
}
|
|
@@ -10,17 +10,19 @@
|
|
|
10
10
|
* Admin shell layout — authenticated-only.
|
|
11
11
|
*
|
|
12
12
|
* `beforeLoad` resolves the current admin user (which internally verifies
|
|
13
|
-
* the access credential without rotating cookies). Expired access
|
|
14
|
-
* renewed
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* the access credential without rotating cookies). Expired access on a
|
|
14
|
+
* browser-side navigation is renewed in place and the load retried, so the
|
|
15
|
+
* user stays on the requested URL; expired access on a server render, and
|
|
16
|
+
* every other unauthenticated outcome, redirects to `/sign-in` with a
|
|
17
|
+
* `callbackUrl` so the user lands back on the page they originally
|
|
18
|
+
* requested. See `admin-layout-guard.ts` for the decision table.
|
|
17
19
|
*
|
|
18
20
|
* The resolved user is returned as route context so every nested admin
|
|
19
21
|
* route (and the `AdminAppBar`) can read it without an extra fetch.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import { useEffect } from 'react'
|
|
23
|
-
import { createFileRoute, Outlet
|
|
25
|
+
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
|
24
26
|
|
|
25
27
|
import { applyStoredTheme } from '@byline/admin/admin-account/components/theme'
|
|
26
28
|
import { BylineFieldServicesProvider } from '@byline/admin/react'
|
|
@@ -43,55 +45,40 @@ import { bylineAdminServices } from '../integrations/byline-admin-services.js'
|
|
|
43
45
|
import { BylineAiAdminProvider } from '../integrations/byline-ai.js'
|
|
44
46
|
import { bylineFieldServices } from '../integrations/byline-field-services.js'
|
|
45
47
|
import { observeSession, sessionIsChanged } from '../integrations/session-coordination.js'
|
|
48
|
+
import { renewExpectedSession } from '../integrations/session-renewal.js'
|
|
46
49
|
import { getAnalyticsRuntime } from '../server-fns/analytics/index.js'
|
|
47
50
|
import { getCurrentAdminUser } from '../server-fns/auth/index.js'
|
|
48
51
|
import { getScheduledPublicationRuntime } from '../server-fns/collections/index.js'
|
|
49
52
|
import { getActiveLocaleFn, setAdminLocaleFn } from '../server-fns/i18n/index.js'
|
|
53
|
+
import { loadAdminLayoutContext } from './admin-layout-guard.js'
|
|
50
54
|
import { getSignInRoutePath } from './sign-in-path.js'
|
|
51
55
|
|
|
52
56
|
export function createAdminLayoutRoute(path: string) {
|
|
53
57
|
const Route: any = createFileRoute(path as never)({
|
|
54
|
-
beforeLoad:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'ERR_INVALID_TOKEN',
|
|
79
|
-
'ERR_REVOKED_TOKEN',
|
|
80
|
-
'ERR_ACCOUNT_DISABLED',
|
|
81
|
-
'ERR_SESSION_CHANGED',
|
|
82
|
-
].includes(code ?? '')
|
|
83
|
-
)
|
|
84
|
-
throw error
|
|
85
|
-
// `getCurrentAdminUser` (via `getAdminRequestContext`) throws
|
|
86
|
-
// `ERR_UNAUTHENTICATED` or a related auth error when no valid
|
|
87
|
-
// session is present. Redirect to sign-in with the original path
|
|
88
|
-
// so we can send the user back after they authenticate.
|
|
89
|
-
throw redirect({
|
|
90
|
-
to: signInPath as never,
|
|
91
|
-
search: { callbackUrl: location.href } as never,
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
},
|
|
58
|
+
beforeLoad: ({ location }: { location: { href: string } }) =>
|
|
59
|
+
loadAdminLayoutContext({
|
|
60
|
+
load: async () => {
|
|
61
|
+
const user = await getCurrentAdminUser()
|
|
62
|
+
if (typeof window !== 'undefined') observeSession(user.sessionId)
|
|
63
|
+
// Resolve the active interface locale once on the server so SSR
|
|
64
|
+
// and the hydrated client render the same translations and
|
|
65
|
+
// there's no locale flicker. Going through the server fn (rather
|
|
66
|
+
// than calling `resolveRequestLocale` directly) keeps
|
|
67
|
+
// `@tanstack/react-start/server` out of the client bundle — the
|
|
68
|
+
// same pattern `getCurrentAdminUser` uses for `getAdminRequestContext`.
|
|
69
|
+
const [activeLocale, scheduledPublicationRuntime, analyticsRuntime] = await Promise.all([
|
|
70
|
+
getActiveLocaleFn(),
|
|
71
|
+
getScheduledPublicationRuntime(),
|
|
72
|
+
getAnalyticsRuntime(),
|
|
73
|
+
])
|
|
74
|
+
return { user, activeLocale, scheduledPublicationRuntime, analyticsRuntime }
|
|
75
|
+
},
|
|
76
|
+
renewSession: renewExpectedSession,
|
|
77
|
+
isBrowser: typeof window !== 'undefined',
|
|
78
|
+
sessionChanged: sessionIsChanged,
|
|
79
|
+
signInPath: getSignInRoutePath(),
|
|
80
|
+
href: location.href,
|
|
81
|
+
}),
|
|
95
82
|
component: function AdminLayoutComponent() {
|
|
96
83
|
const { user, activeLocale, scheduledPublicationRuntime, analyticsRuntime } =
|
|
97
84
|
Route.useRouteContext() as {
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { ERR_ACCESS_EXPIRED, ERR_REVOKED_TOKEN, ERR_UNAUTHENTICATED } from '@byline/auth'
|
|
10
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({
|
|
13
|
+
context: vi.fn(),
|
|
14
|
+
refresh: 'refresh' as string | undefined,
|
|
15
|
+
getById: vi.fn(),
|
|
16
|
+
}))
|
|
17
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
18
|
+
createServerFn: () => ({ handler: (handler: any) => ({ handler }) }),
|
|
19
|
+
}))
|
|
20
|
+
vi.mock('@byline/client/server', () => ({
|
|
21
|
+
getAdminRequestContext: mocks.context,
|
|
22
|
+
readRefreshTokenCookie: () => mocks.refresh,
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('../../integrations/byline-core.js', () => ({
|
|
25
|
+
bylineCore: () => ({ adminStore: { adminUsers: { getById: mocks.getById } } }),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
import { getCurrentAdminSessionSoft } from './current-user.js'
|
|
29
|
+
|
|
30
|
+
const endpoint = getCurrentAdminSessionSoft as unknown as { handler: () => Promise<any> }
|
|
31
|
+
const row = {
|
|
32
|
+
id: 'admin-1',
|
|
33
|
+
email: 'editor@example.test',
|
|
34
|
+
given_name: null,
|
|
35
|
+
family_name: null,
|
|
36
|
+
is_super_admin: false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.resetAllMocks()
|
|
41
|
+
mocks.refresh = 'refresh'
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('getCurrentAdminSessionSoft', () => {
|
|
45
|
+
it('returns the user and no renewal hint when the access credential verifies', async () => {
|
|
46
|
+
mocks.context.mockResolvedValue({
|
|
47
|
+
actor: { id: 'admin-1', abilities: new Set(['collections.news.read']) },
|
|
48
|
+
sessionId: 'login-1',
|
|
49
|
+
})
|
|
50
|
+
mocks.getById.mockResolvedValue(row)
|
|
51
|
+
await expect(endpoint.handler()).resolves.toEqual({
|
|
52
|
+
user: { ...row, sessionId: 'login-1', abilities: ['collections.news.read'] },
|
|
53
|
+
renewable: false,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reports a renewable session when access expired and a refresh credential is present', async () => {
|
|
58
|
+
mocks.context.mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' }))
|
|
59
|
+
await expect(endpoint.handler()).resolves.toEqual({ user: null, renewable: true })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not report renewable when access expired without a refresh credential', async () => {
|
|
63
|
+
mocks.refresh = undefined
|
|
64
|
+
mocks.context.mockRejectedValue(ERR_ACCESS_EXPIRED({ message: 'expired' }))
|
|
65
|
+
await expect(endpoint.handler()).resolves.toEqual({ user: null, renewable: false })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
ERR_UNAUTHENTICATED({ message: 'anonymous' }),
|
|
70
|
+
ERR_REVOKED_TOKEN({ message: 'revoked' }),
|
|
71
|
+
new Error('database unavailable'),
|
|
72
|
+
])('stays anonymous and non-renewable on %s', async (error) => {
|
|
73
|
+
mocks.context.mockRejectedValue(error)
|
|
74
|
+
await expect(endpoint.handler()).resolves.toEqual({ user: null, renewable: false })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('stays anonymous when the session points at a deleted admin', async () => {
|
|
78
|
+
mocks.context.mockResolvedValue({
|
|
79
|
+
actor: { id: 'gone', abilities: new Set() },
|
|
80
|
+
sessionId: 'login-1',
|
|
81
|
+
})
|
|
82
|
+
mocks.getById.mockResolvedValue(null)
|
|
83
|
+
await expect(endpoint.handler()).resolves.toEqual({ user: null, renewable: false })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { createServerFn } from '@tanstack/react-start'
|
|
22
22
|
|
|
23
23
|
import { type Actor, AuthError } from '@byline/auth'
|
|
24
|
-
import { getAdminRequestContext } from '@byline/client/server'
|
|
24
|
+
import { getAdminRequestContext, readRefreshTokenCookie } from '@byline/client/server'
|
|
25
25
|
|
|
26
26
|
import { bylineCore } from '../../integrations/byline-core.js'
|
|
27
27
|
|
|
@@ -76,30 +76,59 @@ export const getCurrentAdminUser = createServerFn({ method: 'GET' }).handler(
|
|
|
76
76
|
)
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Soft
|
|
80
|
-
* renders only when the visitor is signed in).
|
|
79
|
+
* Soft variants for public-page consumers (e.g. the content admin bar that
|
|
80
|
+
* renders only when the visitor is signed in). Return `null` instead of
|
|
81
81
|
* throwing when there is no session — anonymous visitors are the common
|
|
82
82
|
* case here, so a thrown error would be wrong shape for a public route
|
|
83
83
|
* loader. Other failure modes (DB unavailable, session points to a
|
|
84
84
|
* deleted admin) are still coerced to `null` so the bar simply hides.
|
|
85
|
+
*
|
|
86
|
+
* `getCurrentAdminSessionSoft` additionally reports `renewable`: true when
|
|
87
|
+
* the access credential has expired but a refresh credential is present.
|
|
88
|
+
* Public reads never rotate credentials, so without this hint a signed-in
|
|
89
|
+
* editor's admin bar and preview mode silently vanish after the access
|
|
90
|
+
* lifetime. The host's `AdminSessionRecovery` component consumes it to run
|
|
91
|
+
* the coordinated browser renewal and re-run the page's loaders.
|
|
92
|
+
*
|
|
93
|
+
* `getCurrentAdminUserSoft` keeps the original `CurrentAdminUser | null`
|
|
94
|
+
* shape for existing integrations.
|
|
85
95
|
*/
|
|
96
|
+
export interface CurrentAdminSession {
|
|
97
|
+
user: CurrentAdminUser | null
|
|
98
|
+
/** Access expired with a refresh credential present: the browser may renew and reload data. */
|
|
99
|
+
renewable: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const getCurrentAdminSessionSoft = createServerFn({ method: 'GET' }).handler(
|
|
103
|
+
async (): Promise<CurrentAdminSession> => resolveSoftSession()
|
|
104
|
+
)
|
|
105
|
+
|
|
86
106
|
export const getCurrentAdminUserSoft = createServerFn({ method: 'GET' }).handler(
|
|
87
|
-
async (): Promise<CurrentAdminUser | null> =>
|
|
107
|
+
async (): Promise<CurrentAdminUser | null> => (await resolveSoftSession()).user
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const anonymous: CurrentAdminSession = { user: null, renewable: false }
|
|
111
|
+
|
|
112
|
+
async function resolveSoftSession(): Promise<CurrentAdminSession> {
|
|
113
|
+
try {
|
|
114
|
+
let actor: Actor
|
|
115
|
+
let sessionId: string | undefined
|
|
88
116
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (err instanceof AuthError) return null
|
|
95
|
-
throw err
|
|
117
|
+
;({ actor, sessionId } = await getAdminRequestContext())
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof AuthError) {
|
|
120
|
+
const renewable = err.code === 'ERR_ACCESS_EXPIRED' && readRefreshTokenCookie() != null
|
|
121
|
+
return { user: null, renewable }
|
|
96
122
|
}
|
|
97
|
-
|
|
123
|
+
throw err
|
|
124
|
+
}
|
|
125
|
+
if (!actor) return anonymous
|
|
98
126
|
|
|
99
|
-
|
|
100
|
-
|
|
127
|
+
const row = await bylineCore().adminStore?.adminUsers.getById(actor.id)
|
|
128
|
+
if (!row) return anonymous
|
|
101
129
|
|
|
102
|
-
|
|
130
|
+
return {
|
|
131
|
+
user: {
|
|
103
132
|
sessionId: sessionId!,
|
|
104
133
|
id: row.id,
|
|
105
134
|
email: row.email,
|
|
@@ -107,9 +136,10 @@ export const getCurrentAdminUserSoft = createServerFn({ method: 'GET' }).handler
|
|
|
107
136
|
family_name: row.family_name,
|
|
108
137
|
is_super_admin: row.is_super_admin,
|
|
109
138
|
abilities: Array.from(actor.abilities),
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return null
|
|
139
|
+
},
|
|
140
|
+
renewable: false,
|
|
113
141
|
}
|
|
142
|
+
} catch {
|
|
143
|
+
return anonymous
|
|
114
144
|
}
|
|
115
|
-
|
|
145
|
+
}
|