@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
@@ -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 is
14
- * renewed by the sign-in bootstrap. On unauthenticated
15
- * requests we redirect to `/sign-in` with a `callbackUrl` so the user
16
- * lands back on the page they originally requested.
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, redirect } from '@tanstack/react-router'
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: async ({ location }: { location: { href: string } }) => {
55
- const signInPath = getSignInRoutePath()
56
- try {
57
- const user = await getCurrentAdminUser()
58
- if (typeof window !== 'undefined') observeSession(user.sessionId)
59
- // Resolve the active interface locale once on the server so SSR
60
- // and the hydrated client render the same translations and
61
- // there's no locale flicker. Going through the server fn (rather
62
- // than calling `resolveRequestLocale` directly) keeps
63
- // `@tanstack/react-start/server` out of the client bundle — the
64
- // same pattern `getCurrentAdminUser` uses for `getAdminRequestContext`.
65
- const [activeLocale, scheduledPublicationRuntime, analyticsRuntime] = await Promise.all([
66
- getActiveLocaleFn(),
67
- getScheduledPublicationRuntime(),
68
- getAnalyticsRuntime(),
69
- ])
70
- return { user, activeLocale, scheduledPublicationRuntime, analyticsRuntime }
71
- } catch (error) {
72
- const code = (error as { code?: string })?.code
73
- if (
74
- !(typeof window !== 'undefined' && sessionIsChanged()) &&
75
- ![
76
- 'ERR_UNAUTHENTICATED',
77
- 'ERR_ACCESS_EXPIRED',
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 variant for public-page consumers (e.g. the content admin bar that
80
- * renders only when the visitor is signed in). Returns `null` instead of
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
- let actor: Actor
90
- let sessionId: string | undefined
91
- try {
92
- ;({ actor, sessionId } = await getAdminRequestContext())
93
- } catch (err) {
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
- if (!actor) return null
123
+ throw err
124
+ }
125
+ if (!actor) return anonymous
98
126
 
99
- const row = await bylineCore().adminStore?.adminUsers.getById(actor.id)
100
- if (!row) return null
127
+ const row = await bylineCore().adminStore?.adminUsers.getById(actor.id)
128
+ if (!row) return anonymous
101
129
 
102
- return {
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
- } catch {
112
- return null
139
+ },
140
+ renewable: false,
113
141
  }
142
+ } catch {
143
+ return anonymous
114
144
  }
115
- )
145
+ }
@@ -7,7 +7,9 @@
7
7
  */
8
8
 
9
9
  export {
10
+ type CurrentAdminSession,
10
11
  type CurrentAdminUser,
12
+ getCurrentAdminSessionSoft,
11
13
  getCurrentAdminUser,
12
14
  getCurrentAdminUserSoft,
13
15
  } from './current-user.js'