@byline/host-tanstack-start 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/dist/admin-shell/chrome/admin-app-bar.d.ts +7 -0
  2. package/dist/admin-shell/chrome/admin-app-bar.js +10 -2
  3. package/dist/admin-shell/chrome/optional-i18n-provider.d.ts +29 -0
  4. package/dist/admin-shell/chrome/optional-i18n-provider.js +24 -0
  5. package/dist/admin-shell/chrome/session-change-boundary.d.ts +14 -0
  6. package/dist/admin-shell/chrome/session-change-boundary.js +119 -0
  7. package/dist/admin-shell/chrome/session-change-boundary.module.js +11 -0
  8. package/dist/admin-shell/chrome/session-change-boundary.test.d.ts +8 -0
  9. package/dist/admin-shell/chrome/session-change-boundary_module.css +81 -0
  10. package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -1
  11. package/dist/admin-shell/chrome/sign-in-page.js +60 -3
  12. package/dist/integrations/byline-admin-services.js +7 -1
  13. package/dist/integrations/client-ip.d.ts +19 -0
  14. package/dist/integrations/client-ip.js +34 -0
  15. package/dist/integrations/session-coordination.d.ts +24 -0
  16. package/dist/integrations/session-coordination.js +85 -0
  17. package/dist/integrations/session-middleware.d.ts +11 -0
  18. package/dist/integrations/session-middleware.js +94 -0
  19. package/dist/integrations/session-request-kind.d.ts +9 -0
  20. package/dist/integrations/session-request-kind.js +10 -0
  21. package/dist/integrations/session-request-middleware.d.ts +9 -0
  22. package/dist/integrations/session-request-middleware.js +7 -0
  23. package/dist/integrations/sign-in-body.d.ts +12 -0
  24. package/dist/integrations/sign-in-body.js +52 -0
  25. package/dist/integrations/sign-in-middleware.d.ts +9 -0
  26. package/dist/integrations/sign-in-middleware.js +10 -0
  27. package/dist/routes/create-admin-layout-route.js +46 -31
  28. package/dist/routes/create-sign-in-route.js +4 -2
  29. package/dist/server-fns/admin-account/change-password.d.ts +3 -1
  30. package/dist/server-fns/admin-account/change-password.js +9 -3
  31. package/dist/server-fns/admin-account/get.d.ts +3 -1
  32. package/dist/server-fns/admin-account/get.js +4 -1
  33. package/dist/server-fns/admin-account/index.d.ts +5 -5
  34. package/dist/server-fns/admin-account/update.d.ts +3 -1
  35. package/dist/server-fns/admin-account/update.js +4 -1
  36. package/dist/server-fns/admin-activity/get.d.ts +3 -1
  37. package/dist/server-fns/admin-activity/get.js +4 -1
  38. package/dist/server-fns/admin-permissions/get-role-abilities.d.ts +3 -1
  39. package/dist/server-fns/admin-permissions/get-role-abilities.js +4 -1
  40. package/dist/server-fns/admin-permissions/index.d.ts +4 -4
  41. package/dist/server-fns/admin-permissions/list-registered.d.ts +3 -1
  42. package/dist/server-fns/admin-permissions/list-registered.js +4 -1
  43. package/dist/server-fns/admin-permissions/set-role-abilities.d.ts +3 -1
  44. package/dist/server-fns/admin-permissions/set-role-abilities.js +4 -1
  45. package/dist/server-fns/admin-permissions/who-has.d.ts +3 -1
  46. package/dist/server-fns/admin-permissions/who-has.js +4 -1
  47. package/dist/server-fns/admin-roles/create.d.ts +3 -1
  48. package/dist/server-fns/admin-roles/create.js +4 -1
  49. package/dist/server-fns/admin-roles/delete.d.ts +3 -1
  50. package/dist/server-fns/admin-roles/delete.js +4 -1
  51. package/dist/server-fns/admin-roles/get.d.ts +3 -1
  52. package/dist/server-fns/admin-roles/get.js +4 -1
  53. package/dist/server-fns/admin-roles/index.d.ts +6 -6
  54. package/dist/server-fns/admin-roles/list.d.ts +3 -1
  55. package/dist/server-fns/admin-roles/list.js +4 -1
  56. package/dist/server-fns/admin-roles/reorder.d.ts +3 -1
  57. package/dist/server-fns/admin-roles/reorder.js +4 -1
  58. package/dist/server-fns/admin-roles/update.d.ts +3 -1
  59. package/dist/server-fns/admin-roles/update.js +4 -1
  60. package/dist/server-fns/admin-users/create.d.ts +3 -1
  61. package/dist/server-fns/admin-users/create.js +4 -1
  62. package/dist/server-fns/admin-users/delete.d.ts +3 -1
  63. package/dist/server-fns/admin-users/delete.js +4 -1
  64. package/dist/server-fns/admin-users/disable.d.ts +3 -1
  65. package/dist/server-fns/admin-users/disable.js +4 -1
  66. package/dist/server-fns/admin-users/enable.d.ts +3 -1
  67. package/dist/server-fns/admin-users/enable.js +4 -1
  68. package/dist/server-fns/admin-users/get-user-roles.d.ts +3 -1
  69. package/dist/server-fns/admin-users/get-user-roles.js +4 -1
  70. package/dist/server-fns/admin-users/get.d.ts +3 -1
  71. package/dist/server-fns/admin-users/get.js +4 -1
  72. package/dist/server-fns/admin-users/index.d.ts +10 -10
  73. package/dist/server-fns/admin-users/list.d.ts +3 -1
  74. package/dist/server-fns/admin-users/list.js +4 -1
  75. package/dist/server-fns/admin-users/set-password.d.ts +3 -1
  76. package/dist/server-fns/admin-users/set-password.js +4 -1
  77. package/dist/server-fns/admin-users/set-user-roles.d.ts +3 -1
  78. package/dist/server-fns/admin-users/set-user-roles.js +4 -1
  79. package/dist/server-fns/admin-users/update.d.ts +3 -1
  80. package/dist/server-fns/admin-users/update.js +4 -1
  81. package/dist/server-fns/ai/execute.d.ts +3 -1
  82. package/dist/server-fns/ai/execute.js +4 -1
  83. package/dist/server-fns/ai/index.d.ts +1 -1
  84. package/dist/server-fns/analytics/maintenance.d.ts +6 -2
  85. package/dist/server-fns/analytics/maintenance.js +7 -2
  86. package/dist/server-fns/analytics/queries.d.ts +18 -6
  87. package/dist/server-fns/analytics/queries.js +19 -6
  88. package/dist/server-fns/auth/current-user.d.ts +1 -0
  89. package/dist/server-fns/auth/current-user.js +5 -2
  90. package/dist/server-fns/auth/renew.d.ts +13 -0
  91. package/dist/server-fns/auth/renew.js +45 -0
  92. package/dist/server-fns/auth/sign-in.d.ts +2 -1
  93. package/dist/server-fns/auth/sign-in.js +88 -17
  94. package/dist/server-fns/auth/sign-out.d.ts +3 -1
  95. package/dist/server-fns/auth/sign-out.js +38 -6
  96. package/dist/server-fns/collections/audit.d.ts +3 -1
  97. package/dist/server-fns/collections/audit.js +4 -1
  98. package/dist/server-fns/collections/copy-to-locale.d.ts +10 -1
  99. package/dist/server-fns/collections/copy-to-locale.js +5 -2
  100. package/dist/server-fns/collections/create.d.ts +10 -1
  101. package/dist/server-fns/collections/create.js +5 -2
  102. package/dist/server-fns/collections/delete-locale.d.ts +10 -1
  103. package/dist/server-fns/collections/delete-locale.js +5 -2
  104. package/dist/server-fns/collections/delete.d.ts +10 -1
  105. package/dist/server-fns/collections/delete.js +5 -2
  106. package/dist/server-fns/collections/duplicate.d.ts +10 -1
  107. package/dist/server-fns/collections/duplicate.js +5 -2
  108. package/dist/server-fns/collections/get.d.ts +7 -0
  109. package/dist/server-fns/collections/get.js +8 -3
  110. package/dist/server-fns/collections/history.d.ts +3 -1
  111. package/dist/server-fns/collections/history.js +4 -1
  112. package/dist/server-fns/collections/index.d.ts +21 -21
  113. package/dist/server-fns/collections/list.d.ts +10 -1
  114. package/dist/server-fns/collections/list.js +5 -2
  115. package/dist/server-fns/collections/reindex.d.ts +3 -1
  116. package/dist/server-fns/collections/reindex.js +4 -1
  117. package/dist/server-fns/collections/reorder.d.ts +10 -1
  118. package/dist/server-fns/collections/reorder.js +5 -2
  119. package/dist/server-fns/collections/restore-version.d.ts +10 -1
  120. package/dist/server-fns/collections/restore-version.js +5 -2
  121. package/dist/server-fns/collections/scheduled-publication.d.ts +22 -5
  122. package/dist/server-fns/collections/scheduled-publication.js +17 -6
  123. package/dist/server-fns/collections/set-list-view-preference.d.ts +3 -1
  124. package/dist/server-fns/collections/set-list-view-preference.js +4 -1
  125. package/dist/server-fns/collections/stats.js +4 -1
  126. package/dist/server-fns/collections/status.d.ts +13 -2
  127. package/dist/server-fns/collections/status.js +8 -3
  128. package/dist/server-fns/collections/tree.d.ts +22 -5
  129. package/dist/server-fns/collections/tree.js +17 -6
  130. package/dist/server-fns/collections/update.d.ts +13 -2
  131. package/dist/server-fns/collections/update.js +8 -3
  132. package/dist/server-fns/collections/upload.d.ts +10 -1
  133. package/dist/server-fns/collections/upload.js +4 -1
  134. package/dist/server-fns/i18n/set-locale.d.ts +3 -1
  135. package/dist/server-fns/i18n/set-locale.js +18 -3
  136. package/dist/server-fns/preview/disable.d.ts +3 -1
  137. package/dist/server-fns/preview/disable.js +4 -1
  138. package/dist/server-fns/preview/enable.d.ts +3 -1
  139. package/dist/server-fns/preview/enable.js +4 -1
  140. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +10 -1
  141. package/dist/server-fns/singletons/cancel-scheduled-publish.js +5 -2
  142. package/dist/server-fns/singletons/change-status.d.ts +10 -1
  143. package/dist/server-fns/singletons/change-status.js +5 -2
  144. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +10 -1
  145. package/dist/server-fns/singletons/confirm-scheduled-publish.js +5 -2
  146. package/dist/server-fns/singletons/copy-to-locale.d.ts +10 -1
  147. package/dist/server-fns/singletons/copy-to-locale.js +5 -2
  148. package/dist/server-fns/singletons/find-by-version.d.ts +3 -1
  149. package/dist/server-fns/singletons/find-by-version.js +4 -1
  150. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +3 -1
  151. package/dist/server-fns/singletons/get-scheduled-publish.js +4 -1
  152. package/dist/server-fns/singletons/get.d.ts +3 -1
  153. package/dist/server-fns/singletons/get.js +4 -1
  154. package/dist/server-fns/singletons/history.d.ts +3 -1
  155. package/dist/server-fns/singletons/history.js +4 -1
  156. package/dist/server-fns/singletons/restore-version.d.ts +10 -1
  157. package/dist/server-fns/singletons/restore-version.js +5 -2
  158. package/dist/server-fns/singletons/schedule-publish.d.ts +10 -1
  159. package/dist/server-fns/singletons/schedule-publish.js +5 -2
  160. package/dist/server-fns/singletons/unpublish.d.ts +10 -1
  161. package/dist/server-fns/singletons/unpublish.js +5 -2
  162. package/dist/server-fns/singletons/update.d.ts +10 -1
  163. package/dist/server-fns/singletons/update.js +5 -2
  164. package/package.json +19 -19
  165. package/src/admin-shell/chrome/admin-app-bar.tsx +11 -6
  166. package/src/admin-shell/chrome/optional-i18n-provider.tsx +61 -0
  167. package/src/admin-shell/chrome/session-change-boundary.module.css +112 -0
  168. package/src/admin-shell/chrome/session-change-boundary.test.tsx +91 -0
  169. package/src/admin-shell/chrome/session-change-boundary.tsx +155 -0
  170. package/src/admin-shell/chrome/sign-in-page.tsx +68 -3
  171. package/src/integrations/byline-admin-services.ts +7 -1
  172. package/src/integrations/client-ip.test.node.ts +45 -0
  173. package/src/integrations/client-ip.ts +54 -0
  174. package/src/integrations/framework-fetch-shape.test.node.ts +56 -0
  175. package/src/integrations/session-client.test.node.ts +121 -0
  176. package/src/integrations/session-coordination.test.node.ts +77 -0
  177. package/src/integrations/session-coordination.ts +96 -0
  178. package/src/integrations/session-middleware.test.node.ts +87 -0
  179. package/src/integrations/session-middleware.ts +107 -0
  180. package/src/integrations/session-request-kind.ts +18 -0
  181. package/src/integrations/session-request-middleware.ts +17 -0
  182. package/src/integrations/sign-in-body.test.node.ts +59 -0
  183. package/src/integrations/sign-in-body.ts +50 -0
  184. package/src/integrations/sign-in-middleware.ts +24 -0
  185. package/src/routes/create-admin-layout-route.tsx +48 -30
  186. package/src/routes/create-sign-in-route.tsx +10 -2
  187. package/src/server-fns/admin-account/change-password.test.node.ts +69 -0
  188. package/src/server-fns/admin-account/change-password.ts +13 -2
  189. package/src/server-fns/admin-account/get.ts +2 -0
  190. package/src/server-fns/admin-account/update.ts +2 -0
  191. package/src/server-fns/admin-activity/get.ts +2 -0
  192. package/src/server-fns/admin-permissions/get-role-abilities.ts +2 -0
  193. package/src/server-fns/admin-permissions/list-registered.ts +5 -4
  194. package/src/server-fns/admin-permissions/set-role-abilities.ts +2 -0
  195. package/src/server-fns/admin-permissions/who-has.ts +2 -0
  196. package/src/server-fns/admin-roles/create.ts +2 -0
  197. package/src/server-fns/admin-roles/delete.ts +2 -0
  198. package/src/server-fns/admin-roles/get.ts +2 -0
  199. package/src/server-fns/admin-roles/list.ts +5 -4
  200. package/src/server-fns/admin-roles/reorder.ts +2 -0
  201. package/src/server-fns/admin-roles/update.ts +2 -0
  202. package/src/server-fns/admin-users/create.ts +2 -0
  203. package/src/server-fns/admin-users/delete.ts +2 -0
  204. package/src/server-fns/admin-users/disable.ts +2 -0
  205. package/src/server-fns/admin-users/enable.ts +2 -0
  206. package/src/server-fns/admin-users/get-user-roles.ts +2 -0
  207. package/src/server-fns/admin-users/get.ts +2 -0
  208. package/src/server-fns/admin-users/list.ts +2 -0
  209. package/src/server-fns/admin-users/set-password.ts +2 -0
  210. package/src/server-fns/admin-users/set-user-roles.ts +2 -0
  211. package/src/server-fns/admin-users/update.ts +2 -0
  212. package/src/server-fns/ai/execute.ts +3 -0
  213. package/src/server-fns/analytics/maintenance.ts +4 -0
  214. package/src/server-fns/analytics/queries.ts +14 -8
  215. package/src/server-fns/auth/current-user.ts +7 -3
  216. package/src/server-fns/auth/renew.test.node.ts +85 -0
  217. package/src/server-fns/auth/renew.ts +54 -0
  218. package/src/server-fns/auth/sign-in.test.node.ts +114 -0
  219. package/src/server-fns/auth/sign-in.ts +80 -27
  220. package/src/server-fns/auth/sign-out.test.node.ts +85 -0
  221. package/src/server-fns/auth/sign-out.ts +44 -19
  222. package/src/server-fns/collections/audit.ts +2 -0
  223. package/src/server-fns/collections/copy-to-locale.ts +3 -1
  224. package/src/server-fns/collections/create.ts +3 -1
  225. package/src/server-fns/collections/delete-locale.ts +3 -1
  226. package/src/server-fns/collections/delete.ts +3 -1
  227. package/src/server-fns/collections/duplicate.ts +3 -1
  228. package/src/server-fns/collections/get.ts +4 -1
  229. package/src/server-fns/collections/history.ts +2 -0
  230. package/src/server-fns/collections/list.ts +3 -1
  231. package/src/server-fns/collections/reindex.ts +2 -0
  232. package/src/server-fns/collections/reorder.ts +3 -1
  233. package/src/server-fns/collections/restore-version.ts +3 -1
  234. package/src/server-fns/collections/revision-input.test.node.ts +14 -0
  235. package/src/server-fns/collections/scheduled-publication.ts +14 -7
  236. package/src/server-fns/collections/set-list-view-preference.ts +2 -0
  237. package/src/server-fns/collections/stats.ts +2 -0
  238. package/src/server-fns/collections/status.ts +4 -1
  239. package/src/server-fns/collections/tree.ts +7 -1
  240. package/src/server-fns/collections/update.ts +4 -1
  241. package/src/server-fns/collections/upload-singleton.test.node.ts +8 -1
  242. package/src/server-fns/collections/upload.ts +10 -0
  243. package/src/server-fns/i18n/set-locale.ts +19 -3
  244. package/src/server-fns/preview/disable.ts +8 -4
  245. package/src/server-fns/preview/enable.ts +9 -5
  246. package/src/server-fns/singletons/cancel-scheduled-publish.ts +3 -1
  247. package/src/server-fns/singletons/change-status.ts +3 -1
  248. package/src/server-fns/singletons/confirm-scheduled-publish.ts +3 -1
  249. package/src/server-fns/singletons/copy-to-locale.ts +3 -1
  250. package/src/server-fns/singletons/find-by-version.ts +2 -0
  251. package/src/server-fns/singletons/get-scheduled-publish.ts +2 -0
  252. package/src/server-fns/singletons/get.ts +2 -0
  253. package/src/server-fns/singletons/history.ts +2 -0
  254. package/src/server-fns/singletons/restore-version.ts +3 -1
  255. package/src/server-fns/singletons/schedule-publish.ts +3 -1
  256. package/src/server-fns/singletons/singletons.test.node.ts +6 -0
  257. package/src/server-fns/singletons/unpublish.ts +3 -1
  258. package/src/server-fns/singletons/update.ts +3 -1
@@ -1,5 +1,3 @@
1
- 'use client'
2
-
3
1
  /**
4
2
  * This Source Code is subject to the terms of the Mozilla Public
5
3
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -22,6 +20,8 @@ import { Branding } from './branding.js'
22
20
  import { Breadcrumbs } from './breadcrumbs/breadcrumbs.js'
23
21
  import { useBreadcrumbs } from './breadcrumbs/breadcrumbs-provider.js'
24
22
 
23
+ ;('use client')
24
+
25
25
  interface AdminAppBarProps {
26
26
  user: CurrentAdminUser
27
27
  }
@@ -37,16 +37,18 @@ export function AdminAppBar({ user }: AdminAppBarProps) {
37
37
  const { breadCrumbSettings } = useBreadcrumbs()
38
38
  const { t } = useTranslation('byline-admin')
39
39
  const [signingOut, setSigningOut] = useState(false)
40
+ const [signOutError, setSignOutError] = useState(false)
40
41
 
41
42
  async function handleSignOut() {
42
43
  if (signingOut) return
43
44
  setSigningOut(true)
45
+ setSignOutError(false)
44
46
  try {
45
47
  await adminSignOut()
46
- } catch (err) {
47
- // Even on transport failure, the server-side handler clears the
48
- // cookies best-effort. Navigate to sign-in regardless.
49
- console.warn('sign-out request failed', err)
48
+ } catch {
49
+ setSignOutError(true)
50
+ setSigningOut(false)
51
+ return
50
52
  }
51
53
  // Full-page navigation so the admin guard re-runs with cleared cookies.
52
54
  window.location.assign(getSignInRoutePath())
@@ -71,6 +73,9 @@ export function AdminAppBar({ user }: AdminAppBarProps) {
71
73
  {displayNameFor(user)}
72
74
  </span>
73
75
  </span>
76
+ {signOutError && (
77
+ <span role="alert">Sign-out could not be confirmed. Please try again.</span>
78
+ )}
74
79
  <Button size="xs" intent="secondary" onClick={handleSignOut} disabled={signingOut}>
75
80
  {signingOut ? t('common.actions.signingOut') : t('common.actions.signOut')}
76
81
  </Button>
@@ -0,0 +1,61 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { type ReactNode, useContext } from 'react'
10
+
11
+ import { getAdminConfig } from '@byline/core'
12
+ import type { TranslationBundle } from '@byline/i18n'
13
+ import { I18nContext, I18nProvider } from '@byline/i18n/react'
14
+
15
+ /**
16
+ * Guarantees a translation context for chrome that can render *outside*
17
+ * the admin layout's `<I18nProvider>`.
18
+ *
19
+ * When a provider is already above us it is reused as-is, preserving the
20
+ * user's active locale. Only when none is present does this self-mount.
21
+ *
22
+ * Resilient by design: components using this are the ones shown when
23
+ * something has already gone wrong, so throwing here would mask the real
24
+ * condition. If the admin config can't be read it falls back to an
25
+ * English-default provider with an empty bundle — with a provider always
26
+ * present, `useTranslation` never throws, and a missing key renders as the
27
+ * raw key rather than crashing (see `I18nProvider`'s `onMissing`).
28
+ *
29
+ * `route-error.tsx` carries its own equivalent copy of this logic; it was
30
+ * left in place deliberately rather than refactored alongside an unrelated
31
+ * change, and can adopt this component whenever that file is next touched.
32
+ */
33
+ export function OptionalI18nProvider({ children }: { children: ReactNode }) {
34
+ // Already inside a provider — reuse it (keeps the active locale) and skip
35
+ // the self-mount entirely.
36
+ if (useContext(I18nContext) != null) {
37
+ return <>{children}</>
38
+ }
39
+ let bundle: TranslationBundle = {}
40
+ let locale = 'en'
41
+ try {
42
+ const { i18n } = getAdminConfig()
43
+ bundle = i18n.translations ?? {}
44
+ // No active provider, so render in the default locale — recovering the
45
+ // per-user active locale isn't worth another lookup that could itself throw.
46
+ locale = i18n.admin.defaultLocale
47
+ } catch {
48
+ // Config unavailable (very early boot / broken provider chain) — fall
49
+ // through with the English defaults above.
50
+ }
51
+ return (
52
+ <I18nProvider
53
+ bundle={bundle}
54
+ activeLocale={locale}
55
+ defaultLocale={locale}
56
+ localeDefinitions={[]}
57
+ >
58
+ {children}
59
+ </I18nProvider>
60
+ )
61
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * SessionChangeBoundary — viewport-centred interstitial shown when the
3
+ * active login identity no longer matches the one this page was rendered
4
+ * for. Rare by design: it only appears on an account mismatch.
5
+ *
6
+ * Override handles:
7
+ * .byline-session-change — outer <main>, centres the card in the viewport
8
+ * .byline-session-change-card — the bordered card itself
9
+ * .byline-session-change-title — heading
10
+ * .byline-session-change-body — explanatory paragraph
11
+ * .byline-session-change-account — "Active account: …" line
12
+ * .byline-session-change-error — error paragraph (role="alert")
13
+ * .byline-session-change-actions — button row
14
+ */
15
+
16
+ .main,
17
+ :global(.byline-session-change) {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ min-height: 100dvh;
22
+ padding: 1.5rem;
23
+ /* Bias the card just above the geometric centre — a dialog sitting dead
24
+ centre optically reads as slightly low. The extra bottom padding lifts it
25
+ by half its own value, and being proportional it degrades gracefully on
26
+ short viewports instead of clipping the card off the top. */
27
+ padding-bottom: calc(1.5rem + 12vh);
28
+ }
29
+
30
+ .card,
31
+ :global(.byline-session-change-card) {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 0.75rem;
35
+ width: 100%;
36
+ max-width: 30rem;
37
+ padding: 1.5rem;
38
+ border: var(--border-width-thin) var(--border-style-solid) var(--gray-100);
39
+ border-radius: var(--border-radius-md);
40
+ background-color: var(--canvas-25);
41
+ }
42
+
43
+ :is([data-theme="dark"], :global(.dark)) .card,
44
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-card) {
45
+ border-color: var(--gray-700);
46
+ background-color: var(--canvas-800);
47
+ }
48
+
49
+ .title,
50
+ :global(.byline-session-change-title) {
51
+ margin: 0;
52
+ font-size: var(--font-size-xl);
53
+ font-weight: var(--font-weight-semibold);
54
+ line-height: var(--line-height-snug);
55
+ letter-spacing: var(--letter-spacing-tight);
56
+ color: var(--gray-900);
57
+ }
58
+
59
+ :is([data-theme="dark"], :global(.dark)) .title,
60
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-title) {
61
+ color: var(--gray-100);
62
+ }
63
+
64
+ .body,
65
+ :global(.byline-session-change-body) {
66
+ margin: 0;
67
+ font-size: var(--font-size-sm);
68
+ line-height: var(--line-height-relaxed);
69
+ color: var(--gray-700);
70
+ }
71
+
72
+ :is([data-theme="dark"], :global(.dark)) .body,
73
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-body) {
74
+ color: var(--gray-400);
75
+ }
76
+
77
+ .account,
78
+ :global(.byline-session-change-account) {
79
+ margin: 0;
80
+ font-size: var(--font-size-sm);
81
+ font-weight: var(--font-weight-medium);
82
+ line-height: var(--line-height-normal);
83
+ color: var(--gray-900);
84
+ overflow-wrap: anywhere;
85
+ }
86
+
87
+ :is([data-theme="dark"], :global(.dark)) .account,
88
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-account) {
89
+ color: var(--gray-100);
90
+ }
91
+
92
+ .error,
93
+ :global(.byline-session-change-error) {
94
+ margin: 0;
95
+ font-size: var(--font-size-sm);
96
+ line-height: var(--line-height-relaxed);
97
+ color: var(--red-600);
98
+ }
99
+
100
+ :is([data-theme="dark"], :global(.dark)) .error,
101
+ :is([data-theme="dark"], :global(.dark)) :global(.byline-session-change-error) {
102
+ color: var(--red-400);
103
+ }
104
+
105
+ .actions,
106
+ :global(.byline-session-change-actions) {
107
+ display: flex;
108
+ flex-wrap: wrap;
109
+ align-items: center;
110
+ gap: 0.75rem;
111
+ margin-top: 0.25rem;
112
+ }
@@ -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
+ 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(() => ({ current: vi.fn() }))
15
+ vi.mock('../../server-fns/auth/current-user.js', () => ({ getCurrentAdminUser: mocks.current }))
16
+ vi.mock('../../routes/sign-in-path.js', () => ({ getSignInRoutePath: () => '/sign-in' }))
17
+
18
+ import { adminTranslations } from '@byline/i18n/admin'
19
+ import { I18nProvider } from '@byline/i18n/react'
20
+
21
+ import { acceptSession, expectedSession } from '../../integrations/session-coordination.js'
22
+ import { SessionChangeBoundary } from './session-change-boundary.js'
23
+
24
+ const user = (sessionId: string, email: string) => ({
25
+ sessionId,
26
+ email,
27
+ id: email,
28
+ given_name: null,
29
+ family_name: null,
30
+ is_super_admin: false,
31
+ abilities: [],
32
+ })
33
+ let root: Root
34
+ let container: HTMLDivElement
35
+ beforeEach(() => {
36
+ vi.clearAllMocks()
37
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
38
+ vi.stubGlobal('BroadcastChannel', undefined)
39
+ window.sessionStorage.clear()
40
+ acceptSession('expected-B')
41
+ container = document.createElement('div')
42
+ document.body.appendChild(container)
43
+ root = createRoot(container)
44
+ })
45
+ afterEach(() => {
46
+ act(() => root.unmount())
47
+ container.remove()
48
+ vi.unstubAllGlobals()
49
+ })
50
+ // Mounted inside a real provider so the assertions below read the shipped
51
+ // English strings, and so the boundary's `OptionalI18nProvider` exercises its
52
+ // reuse path (an existing context is preserved rather than self-mounted).
53
+ async function render() {
54
+ await act(async () => {
55
+ root.render(
56
+ <I18nProvider
57
+ bundle={adminTranslations({ locales: ['en'] })}
58
+ activeLocale="en"
59
+ defaultLocale="en"
60
+ localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
61
+ >
62
+ <SessionChangeBoundary user={user('late-A', 'x@example.test')}>
63
+ <button type="button">Publish</button>
64
+ </SessionChangeBoundary>
65
+ </I18nProvider>
66
+ )
67
+ })
68
+ }
69
+ describe('session-change acknowledgement', () => {
70
+ it('blocks business UI on the first post-sign-in render when cookies select a different login', async () => {
71
+ mocks.current.mockResolvedValue(user('late-A', 'x@example.test'))
72
+ await render()
73
+ expect(container.textContent).not.toContain('Publish')
74
+ expect(container.querySelector('[role="alertdialog"]')).not.toBeNull()
75
+ expect(container.textContent).toContain('Active account: x@example.test')
76
+ expect(expectedSession()).toBe('expected-B')
77
+ })
78
+ it('requires renewed acknowledgement when the server account changes again', async () => {
79
+ mocks.current.mockResolvedValue(user('late-A', 'x@example.test'))
80
+ await render()
81
+ mocks.current.mockResolvedValue(user('new-C', 'z@example.test'))
82
+ await act(async () => {
83
+ container.querySelector('button')?.click()
84
+ })
85
+ expect(container.textContent).toContain(
86
+ 'The active session changed again. Review the account before continuing.'
87
+ )
88
+ expect(expectedSession()).toBe('expected-B')
89
+ expect(container.textContent).not.toContain('Publish')
90
+ })
91
+ })
@@ -0,0 +1,155 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
10
+
11
+ import { useTranslation } from '@byline/i18n/react'
12
+ import { Button } from '@byline/ui/react'
13
+ import cx from 'clsx'
14
+
15
+ import {
16
+ acceptSession,
17
+ expectedSession,
18
+ flagSessionChanged,
19
+ observeSession,
20
+ sessionEpoch,
21
+ sessionIsChanged,
22
+ startSessionNotifications,
23
+ subscribeSessionChange,
24
+ } from '../../integrations/session-coordination.js'
25
+ import { getSignInRoutePath } from '../../routes/sign-in-path.js'
26
+ import { type CurrentAdminUser, getCurrentAdminUser } from '../../server-fns/auth/current-user.js'
27
+ import { OptionalI18nProvider } from './optional-i18n-provider.js'
28
+ import styles from './session-change-boundary.module.css'
29
+
30
+ /** Blocks the page until the user explicitly acknowledges freshly verified login identity. */
31
+ export function SessionChangeBoundary({
32
+ user,
33
+ children,
34
+ }: {
35
+ user?: CurrentAdminUser
36
+ children: ReactNode
37
+ }) {
38
+ const epoch = useSyncExternalStore(subscribeSessionChange, sessionEpoch, () => 0)
39
+ const changed = sessionIsChanged()
40
+ const [current, setCurrent] = useState(user)
41
+ const [error, setError] = useState<string>()
42
+ const [busy, setBusy] = useState(false)
43
+ const mismatch =
44
+ typeof window !== 'undefined' &&
45
+ !!user &&
46
+ !!expectedSession() &&
47
+ expectedSession() !== user.sessionId
48
+ useEffect(() => {
49
+ startSessionNotifications()
50
+ if (user) {
51
+ try {
52
+ observeSession(user.sessionId)
53
+ } catch {
54
+ /* Interstitial owns this mismatch. */
55
+ }
56
+ }
57
+ }, [user])
58
+ useEffect(() => {
59
+ if (!changed && !mismatch) return
60
+ void getCurrentAdminUser()
61
+ .then((verified) => {
62
+ if (sessionEpoch() !== epoch) return
63
+ setCurrent(verified)
64
+ setError(undefined)
65
+ })
66
+ .catch(() => setError('sessionChange.errors.unavailable'))
67
+ }, [changed, mismatch, epoch])
68
+ if (!changed && !mismatch) return children
69
+ async function acknowledge() {
70
+ setBusy(true)
71
+ try {
72
+ const verified = await getCurrentAdminUser()
73
+ // Require a second acknowledgement if the identity changed while this interstitial was open.
74
+ if (!current || current.sessionId !== verified.sessionId) {
75
+ setCurrent(verified)
76
+ setError('sessionChange.errors.changedAgain')
77
+ return
78
+ }
79
+ acceptSession(verified.sessionId)
80
+ window.location.reload() // Discard queued edits and old page state; never replay them.
81
+ } catch {
82
+ flagSessionChanged()
83
+ setError('sessionChange.errors.unverifiable')
84
+ } finally {
85
+ setBusy(false)
86
+ }
87
+ }
88
+ return (
89
+ <OptionalI18nProvider>
90
+ <SessionChangeInterstitial
91
+ current={current}
92
+ errorKey={error}
93
+ busy={busy}
94
+ onAcknowledge={acknowledge}
95
+ />
96
+ </OptionalI18nProvider>
97
+ )
98
+ }
99
+
100
+ /**
101
+ * Presentation half. Split out so the error state can be carried as a
102
+ * translation *key* through the logic above and resolved here, inside the
103
+ * provider — the boundary itself renders outside any `<I18nProvider>` at
104
+ * both of its mount sites.
105
+ */
106
+ function SessionChangeInterstitial({
107
+ current,
108
+ errorKey,
109
+ busy,
110
+ onAcknowledge,
111
+ }: {
112
+ current?: CurrentAdminUser
113
+ errorKey?: string
114
+ busy: boolean
115
+ onAcknowledge: () => void
116
+ }) {
117
+ const { t } = useTranslation('byline-admin')
118
+ return (
119
+ <main
120
+ className={cx('byline-session-change', styles.main)}
121
+ role="alertdialog"
122
+ aria-modal="true"
123
+ aria-labelledby="session-change-title"
124
+ >
125
+ <div className={cx('byline-session-change-card', styles.card)}>
126
+ <h1 id="session-change-title" className={cx('byline-session-change-title', styles.title)}>
127
+ {t('sessionChange.title')}
128
+ </h1>
129
+ <p className={cx('byline-session-change-body', styles.body)}>{t('sessionChange.body')}</p>
130
+ {current && (
131
+ <p className={cx('byline-session-change-account', styles.account)}>
132
+ {t('sessionChange.activeAccount', { email: current.email })}
133
+ </p>
134
+ )}
135
+ {errorKey && (
136
+ <p className={cx('byline-session-change-error', styles.error)} role="alert">
137
+ {t(errorKey)}
138
+ </p>
139
+ )}
140
+ <div className={cx('byline-session-change-actions', styles.actions)}>
141
+ <Button type="button" disabled={busy || !current} onClick={onAcknowledge}>
142
+ {t('sessionChange.continue')}
143
+ </Button>
144
+ <Button
145
+ variant="outlined"
146
+ intent="noeffect"
147
+ render={<a href={`${getSignInRoutePath()}?reauthenticate=1`} />}
148
+ >
149
+ {t('sessionChange.signInAgain')}
150
+ </Button>
151
+ </div>
152
+ </div>
153
+ </main>
154
+ )
155
+ }
@@ -6,6 +6,8 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useEffect, useState } from 'react'
10
+
9
11
  import { SignInForm } from '@byline/admin/auth/components/sign-in-form'
10
12
  import { BylineAdminServicesProvider } from '@byline/admin/services'
11
13
  import { getAdminConfig } from '@byline/core'
@@ -15,10 +17,20 @@ import cx from 'clsx'
15
17
 
16
18
  import { buildLocaleDefinitions } from '../../i18n/locale-definitions.js'
17
19
  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'
18
28
  import { setAdminLocaleFn } from '../../server-fns/i18n/index.js'
29
+ import { SessionChangeBoundary } from './session-change-boundary.js'
19
30
  import styles from './sign-in-page.module.css'
20
31
 
21
32
  interface SignInPageProps {
33
+ reauthenticate?: boolean
22
34
  redirectTo: string
23
35
  activeLocale: LocaleCode
24
36
  homeUrl?: string
@@ -44,14 +56,54 @@ interface SignInPageProps {
44
56
  * Threads an optional host-owned `homeUrl` into `SignInForm` so the form's
45
57
  * action row can render a plain "Home" link beside the submit button.
46
58
  */
47
- export function SignInPage({ redirectTo, activeLocale, homeUrl }: SignInPageProps) {
59
+ export function SignInPage({
60
+ redirectTo,
61
+ activeLocale,
62
+ homeUrl,
63
+ reauthenticate = false,
64
+ }: SignInPageProps) {
65
+ const [checking, setChecking] = useState(!reauthenticate)
66
+ const [bootstrapError, setBootstrapError] = useState(false)
67
+ useEffect(() => {
68
+ if (reauthenticate) return
69
+ 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
+ )
78
+ .then(() => {
79
+ if (active) window.location.replace(redirectTo)
80
+ })
81
+ .catch((error) => {
82
+ if (error?.code === 'ERR_SESSION_CHANGED') flagSessionChanged()
83
+ else if (
84
+ ![
85
+ 'ERR_UNAUTHENTICATED',
86
+ 'ERR_INVALID_TOKEN',
87
+ 'ERR_REVOKED_TOKEN',
88
+ 'ERR_ACCOUNT_DISABLED',
89
+ ].includes(error?.code)
90
+ )
91
+ setBootstrapError(true)
92
+ })
93
+ .finally(() => {
94
+ if (active) setChecking(false)
95
+ })
96
+ return () => {
97
+ active = false
98
+ }
99
+ }, [redirectTo, reauthenticate])
48
100
  const { i18n } = getAdminConfig()
49
101
  const localeDefinitions = buildLocaleDefinitions(i18n.admin.locales, i18n.admin.localeDefinitions)
50
102
  const handleSetLocale = async (next: LocaleCode) => {
51
103
  await setAdminLocaleFn({ data: { locale: next } })
52
104
  window.location.reload()
53
105
  }
54
- return (
106
+ const content = (
55
107
  <I18nProvider
56
108
  bundle={i18n.translations ?? {}}
57
109
  activeLocale={activeLocale}
@@ -61,14 +113,27 @@ export function SignInPage({ redirectTo, activeLocale, homeUrl }: SignInPageProp
61
113
  >
62
114
  <BylineAdminServicesProvider services={bylineAdminServices}>
63
115
  <main className={cx('byline-sign-in-page', styles.main)}>
116
+ <noscript>JavaScript is required to sign in or renew your admin session.</noscript>
64
117
  <div className={cx('byline-sign-in-page-bar', styles.bar)}>
65
118
  <LanguageMenu />
66
119
  </div>
67
120
  <div className={cx('byline-sign-in-page-inner', styles.inner)}>
68
- <SignInForm redirectTo={redirectTo} homeUrl={homeUrl} />
121
+ {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>
128
+ ) : checking ? (
129
+ <p role="status">Checking your session…</p>
130
+ ) : (
131
+ <SignInForm redirectTo={redirectTo} homeUrl={homeUrl} />
132
+ )}
69
133
  </div>
70
134
  </main>
71
135
  </BylineAdminServicesProvider>
72
136
  </I18nProvider>
73
137
  )
138
+ return reauthenticate ? content : <SessionChangeBoundary>{content}</SessionChangeBoundary>
74
139
  }
@@ -36,6 +36,7 @@ import {
36
36
  import { adminSignIn } from '../server-fns/auth/index.js'
37
37
  import { getCollectionDocumentVersion as serverGetCollectionDocumentVersion } from '../server-fns/collections/get.js'
38
38
  import { setAdminLocaleFn } from '../server-fns/i18n/index.js'
39
+ import { acceptSession, coordinateAuthAction, notifySessionAction } from './session-coordination.js'
39
40
 
40
41
  /**
41
42
  * Diff helper adapter — the contract uses positional args; the underlying
@@ -62,7 +63,12 @@ const getCollectionDocumentVersion: BylineAdminServices['getCollectionDocumentVe
62
63
 
63
64
  export const bylineAdminServices: BylineAdminServices = {
64
65
  // Auth
65
- adminSignIn,
66
+ adminSignIn: async (args) => {
67
+ const result = await coordinateAuthAction(() => adminSignIn(args))
68
+ acceptSession(result.sessionId)
69
+ notifySessionAction()
70
+ return result
71
+ },
66
72
 
67
73
  // Account self-service
68
74
  updateAccount,
@@ -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
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { createClientIpResolver, normalizeClientIp } from './client-ip.js'
12
+
13
+ describe('client IP trust boundary', () => {
14
+ it('ignores spoofed forwarding headers in direct mode', async () => {
15
+ const resolve = createClientIpResolver({ resolvePeerIp: () => '192.0.2.1' })
16
+ expect(
17
+ await resolve(
18
+ new Request('https://app.test', {
19
+ headers: { 'x-forwarded-for': '198.51.100.1', 'x-real-ip': '198.51.100.2' },
20
+ })
21
+ )
22
+ ).toBe('192.0.2.1')
23
+ })
24
+ it('uses only the configured proxy header and fails closed on absent/invalid values', async () => {
25
+ const resolve = createClientIpResolver({
26
+ trustedProxyHeader: 'x-byline-client-ip',
27
+ resolvePeerIp: () => '127.0.0.1',
28
+ })
29
+ for (const value of ['', '192.0.2.1, 192.0.2.2', 'garbage']) {
30
+ expect(
31
+ await resolve(new Request('https://app.test', { headers: { 'x-byline-client-ip': value } }))
32
+ ).toBeNull()
33
+ }
34
+ expect(
35
+ await resolve(
36
+ new Request('https://app.test', { headers: { 'x-byline-client-ip': '2001:0db8::1' } })
37
+ )
38
+ ).toBe('2001:db8::1')
39
+ })
40
+ it('normalizes equivalent IP representations', () => {
41
+ expect(normalizeClientIp('::ffff:192.0.2.1')).toBe('192.0.2.1')
42
+ expect(normalizeClientIp('::ffff:c000:201')).toBe('192.0.2.1')
43
+ expect(normalizeClientIp('fe80::1%en0')).toBeNull()
44
+ })
45
+ })