@oxyhq/services 13.3.0 → 14.0.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 (245) hide show
  1. package/lib/commonjs/index.js +11 -16
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/AccountMenu.js +103 -65
  4. package/lib/commonjs/ui/components/AccountMenu.js.map +1 -1
  5. package/lib/commonjs/ui/components/AccountSwitcher.js +84 -126
  6. package/lib/commonjs/ui/components/AccountSwitcher.js.map +1 -1
  7. package/lib/commonjs/ui/components/ProfileMenu.js +140 -153
  8. package/lib/commonjs/ui/components/ProfileMenu.js.map +1 -1
  9. package/lib/commonjs/ui/components/SignInModal.js +4 -4
  10. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  11. package/lib/commonjs/ui/context/OxyContext.js +506 -466
  12. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  13. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +59 -57
  14. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  15. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +23 -42
  16. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +1 -1
  17. package/lib/commonjs/ui/context/silentSessionRestore.js +12 -21
  18. package/lib/commonjs/ui/context/silentSessionRestore.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/useAuth.js +4 -3
  20. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  21. package/lib/commonjs/ui/hooks/useOxyAuthSession.js +12 -7
  22. package/lib/commonjs/ui/hooks/useOxyAuthSession.js.map +1 -1
  23. package/lib/commonjs/ui/hooks/useSessionManagement.js +17 -65
  24. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  25. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +288 -0
  26. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -0
  27. package/lib/commonjs/ui/hooks/useWebSSO.js +12 -191
  28. package/lib/commonjs/ui/hooks/useWebSSO.js.map +1 -1
  29. package/lib/commonjs/ui/screens/OxyAuthScreen.js +2 -2
  30. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  31. package/lib/commonjs/ui/session/createSessionClient.js +28 -0
  32. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -0
  33. package/lib/commonjs/ui/session/index.js +52 -0
  34. package/lib/commonjs/ui/session/index.js.map +1 -0
  35. package/lib/commonjs/ui/session/projectSessionState.js +86 -0
  36. package/lib/commonjs/ui/session/projectSessionState.js.map +1 -0
  37. package/lib/commonjs/ui/session/sessionClientHost.js +30 -0
  38. package/lib/commonjs/ui/session/sessionClientHost.js.map +1 -0
  39. package/lib/commonjs/ui/session/tokenTransport.js +77 -0
  40. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -0
  41. package/lib/commonjs/ui/utils/activeAuthuser.js +35 -79
  42. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  43. package/lib/commonjs/ui/utils/sessionHelpers.js +1 -51
  44. package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
  45. package/lib/commonjs/utils/deviceFlowSignIn.js +70 -15
  46. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
  47. package/lib/module/index.js +5 -4
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/ui/components/AccountMenu.js +103 -65
  50. package/lib/module/ui/components/AccountMenu.js.map +1 -1
  51. package/lib/module/ui/components/AccountSwitcher.js +85 -127
  52. package/lib/module/ui/components/AccountSwitcher.js.map +1 -1
  53. package/lib/module/ui/components/ProfileMenu.js +143 -156
  54. package/lib/module/ui/components/ProfileMenu.js.map +1 -1
  55. package/lib/module/ui/components/SignInModal.js +4 -4
  56. package/lib/module/ui/components/SignInModal.js.map +1 -1
  57. package/lib/module/ui/context/OxyContext.js +510 -470
  58. package/lib/module/ui/context/OxyContext.js.map +1 -1
  59. package/lib/module/ui/context/hooks/useAuthOperations.js +61 -60
  60. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  61. package/lib/module/ui/context/inSessionTokenRefresh.js +24 -43
  62. package/lib/module/ui/context/inSessionTokenRefresh.js.map +1 -1
  63. package/lib/module/ui/context/silentSessionRestore.js +12 -20
  64. package/lib/module/ui/context/silentSessionRestore.js.map +1 -1
  65. package/lib/module/ui/hooks/useAuth.js +4 -3
  66. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  67. package/lib/module/ui/hooks/useOxyAuthSession.js +12 -7
  68. package/lib/module/ui/hooks/useOxyAuthSession.js.map +1 -1
  69. package/lib/module/ui/hooks/useSessionManagement.js +18 -66
  70. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  71. package/lib/module/ui/hooks/useSwitchableAccounts.js +283 -0
  72. package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -0
  73. package/lib/module/ui/hooks/useWebSSO.js +13 -192
  74. package/lib/module/ui/hooks/useWebSSO.js.map +1 -1
  75. package/lib/module/ui/screens/OxyAuthScreen.js +2 -2
  76. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  77. package/lib/module/ui/session/createSessionClient.js +25 -0
  78. package/lib/module/ui/session/createSessionClient.js.map +1 -0
  79. package/lib/module/ui/session/index.js +17 -0
  80. package/lib/module/ui/session/index.js.map +1 -0
  81. package/lib/module/ui/session/projectSessionState.js +79 -0
  82. package/lib/module/ui/session/projectSessionState.js.map +1 -0
  83. package/lib/module/ui/session/sessionClientHost.js +26 -0
  84. package/lib/module/ui/session/sessionClientHost.js.map +1 -0
  85. package/lib/module/ui/session/tokenTransport.js +74 -0
  86. package/lib/module/ui/session/tokenTransport.js.map +1 -0
  87. package/lib/module/ui/utils/activeAuthuser.js +34 -76
  88. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  89. package/lib/module/ui/utils/sessionHelpers.js +0 -49
  90. package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
  91. package/lib/module/utils/deviceFlowSignIn.js +71 -16
  92. package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
  93. package/lib/typescript/commonjs/index.d.ts +2 -3
  94. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts +4 -2
  96. package/lib/typescript/commonjs/ui/components/AccountMenu.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts +7 -8
  98. package/lib/typescript/commonjs/ui/components/AccountSwitcher.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts +12 -9
  100. package/lib/typescript/commonjs/ui/components/ProfileMenu.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +16 -13
  102. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  103. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +27 -3
  104. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +22 -17
  106. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +12 -18
  108. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  110. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts +1 -1
  112. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts +2 -2
  114. package/lib/typescript/commonjs/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  117. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +13 -52
  119. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +15 -0
  121. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -0
  122. package/lib/typescript/commonjs/ui/session/index.d.ts +15 -0
  123. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +45 -0
  125. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +14 -0
  127. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +24 -0
  129. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +21 -29
  131. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts +0 -19
  133. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +51 -19
  135. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
  136. package/lib/typescript/module/index.d.ts +2 -3
  137. package/lib/typescript/module/index.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/components/AccountMenu.d.ts +4 -2
  139. package/lib/typescript/module/ui/components/AccountMenu.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts +7 -8
  141. package/lib/typescript/module/ui/components/AccountSwitcher.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/components/ProfileMenu.d.ts +12 -9
  143. package/lib/typescript/module/ui/components/ProfileMenu.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/context/OxyContext.d.ts +16 -13
  145. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +27 -3
  147. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +22 -17
  149. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +12 -18
  151. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +1 -1
  152. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +1 -0
  153. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/hooks/useAuth.d.ts +1 -1
  155. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts +2 -2
  157. package/lib/typescript/module/ui/hooks/useOxyAuthSession.d.ts.map +1 -1
  158. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  159. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts +162 -0
  160. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -0
  161. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +13 -52
  162. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +1 -1
  163. package/lib/typescript/module/ui/session/createSessionClient.d.ts +15 -0
  164. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -0
  165. package/lib/typescript/module/ui/session/index.d.ts +15 -0
  166. package/lib/typescript/module/ui/session/index.d.ts.map +1 -0
  167. package/lib/typescript/module/ui/session/projectSessionState.d.ts +45 -0
  168. package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +1 -0
  169. package/lib/typescript/module/ui/session/sessionClientHost.d.ts +14 -0
  170. package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +1 -0
  171. package/lib/typescript/module/ui/session/tokenTransport.d.ts +24 -0
  172. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -0
  173. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +21 -29
  174. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  175. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts +0 -19
  176. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
  177. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +51 -19
  178. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
  179. package/package.json +4 -4
  180. package/src/index.ts +9 -8
  181. package/src/ui/components/AccountMenu.tsx +117 -70
  182. package/src/ui/components/AccountSwitcher.tsx +88 -134
  183. package/src/ui/components/ProfileMenu.tsx +158 -162
  184. package/src/ui/components/SignInModal.tsx +5 -5
  185. package/src/ui/context/OxyContext.tsx +560 -475
  186. package/src/ui/context/hooks/useAuthOperations.ts +72 -63
  187. package/src/ui/context/inSessionTokenRefresh.ts +23 -42
  188. package/src/ui/context/silentSessionRestore.ts +12 -26
  189. package/src/ui/hooks/useAuth.ts +4 -3
  190. package/src/ui/hooks/useOxyAuthSession.ts +13 -8
  191. package/src/ui/hooks/useSessionManagement.ts +16 -87
  192. package/src/ui/hooks/useSwitchableAccounts.ts +397 -0
  193. package/src/ui/hooks/useWebSSO.ts +13 -227
  194. package/src/ui/screens/OxyAuthScreen.tsx +2 -2
  195. package/src/ui/session/__tests__/createSessionClient.test.ts +41 -0
  196. package/src/ui/session/__tests__/projectSessionState.test.ts +114 -0
  197. package/src/ui/session/__tests__/sessionClientHost.test.ts +43 -0
  198. package/src/ui/session/__tests__/tokenTransport.test.ts +142 -0
  199. package/src/ui/session/createSessionClient.ts +21 -0
  200. package/src/ui/session/index.ts +19 -0
  201. package/src/ui/session/projectSessionState.ts +85 -0
  202. package/src/ui/session/sessionClientHost.ts +27 -0
  203. package/src/ui/session/tokenTransport.ts +76 -0
  204. package/src/ui/utils/activeAuthuser.ts +34 -76
  205. package/src/ui/utils/sessionHelpers.ts +0 -66
  206. package/src/utils/__tests__/deviceFlowSignIn.test.ts +243 -34
  207. package/src/utils/deviceFlowSignIn.ts +98 -21
  208. package/lib/commonjs/ui/components/accountMenuRows.js +0 -33
  209. package/lib/commonjs/ui/components/accountMenuRows.js.map +0 -1
  210. package/lib/commonjs/ui/hooks/useDeviceAccounts.js +0 -280
  211. package/lib/commonjs/ui/hooks/useDeviceAccounts.js.map +0 -1
  212. package/lib/commonjs/ui/hooks/useSessionSocket.js +0 -203
  213. package/lib/commonjs/ui/hooks/useSessionSocket.js.map +0 -1
  214. package/lib/commonjs/utils/silentGuardKey.js +0 -54
  215. package/lib/commonjs/utils/silentGuardKey.js.map +0 -1
  216. package/lib/module/ui/components/accountMenuRows.js +0 -29
  217. package/lib/module/ui/components/accountMenuRows.js.map +0 -1
  218. package/lib/module/ui/hooks/useDeviceAccounts.js +0 -276
  219. package/lib/module/ui/hooks/useDeviceAccounts.js.map +0 -1
  220. package/lib/module/ui/hooks/useSessionSocket.js +0 -199
  221. package/lib/module/ui/hooks/useSessionSocket.js.map +0 -1
  222. package/lib/module/utils/silentGuardKey.js +0 -49
  223. package/lib/module/utils/silentGuardKey.js.map +0 -1
  224. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts +0 -34
  225. package/lib/typescript/commonjs/ui/components/accountMenuRows.d.ts.map +0 -1
  226. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts +0 -133
  227. package/lib/typescript/commonjs/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  228. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts +0 -14
  229. package/lib/typescript/commonjs/ui/hooks/useSessionSocket.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts +0 -31
  231. package/lib/typescript/commonjs/utils/silentGuardKey.d.ts.map +0 -1
  232. package/lib/typescript/module/ui/components/accountMenuRows.d.ts +0 -34
  233. package/lib/typescript/module/ui/components/accountMenuRows.d.ts.map +0 -1
  234. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts +0 -133
  235. package/lib/typescript/module/ui/hooks/useDeviceAccounts.d.ts.map +0 -1
  236. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts +0 -14
  237. package/lib/typescript/module/ui/hooks/useSessionSocket.d.ts.map +0 -1
  238. package/lib/typescript/module/utils/silentGuardKey.d.ts +0 -31
  239. package/lib/typescript/module/utils/silentGuardKey.d.ts.map +0 -1
  240. package/src/ui/components/accountMenuRows.ts +0 -48
  241. package/src/ui/hooks/useDeviceAccounts.ts +0 -344
  242. package/src/ui/hooks/useSessionSocket.ts +0 -233
  243. package/src/ui/utils/__tests__/activeAuthuser.test.ts +0 -75
  244. package/src/utils/__tests__/silentGuardKey.test.ts +0 -82
  245. package/src/utils/silentGuardKey.ts +0 -46
@@ -0,0 +1,397 @@
1
+ import { useMemo } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import {
4
+ getAccountDisplayName,
5
+ getAccountFallbackHandle,
6
+ } from '@oxyhq/core';
7
+ import type {
8
+ User,
9
+ AccountNode,
10
+ AccountRelationship,
11
+ AccountKind,
12
+ AccountMember,
13
+ } from '@oxyhq/core';
14
+ import { useOxy } from '../context/OxyContext';
15
+ import { useI18n } from './useI18n';
16
+ import { queryKeys } from './queries/queryKeys';
17
+
18
+ /**
19
+ * The per-account user shape carried by a {@link SwitchableAccount}. The SDK's
20
+ * canonical {@link User} document — either the freshest `useOxy().user` (the
21
+ * active row), a profile resolved via `oxyServices.getUsersByIds()` (every other
22
+ * device row), or the `account` document embedded in an account-graph node.
23
+ */
24
+ export type SwitchableAccountUser = User;
25
+
26
+ /**
27
+ * One account the signed-in user can switch INTO, in the uniform switch model.
28
+ *
29
+ * A switchable account is either a device sign-in, an account-graph node (owned
30
+ * org / shared-with-you), or BOTH (an account that has been switched into
31
+ * becomes a device session while still being a graph node — the two are deduped
32
+ * into a single row). Every row carries a canonical `accountId` (the uniform
33
+ * switch key passed to `switchToAccount`); `sessionId` is present IFF the
34
+ * account is currently signed in on THIS device.
35
+ */
36
+ export interface SwitchableAccount {
37
+ /**
38
+ * Canonical account id (the underlying `User._id`). The single key EVERY
39
+ * switch uses — `switchToAccount(accountId)`. Always present.
40
+ */
41
+ accountId: string;
42
+ /**
43
+ * Device session id, present IFF this account is signed in on THIS device.
44
+ * Absent for a graph account not yet switched into. Used only for
45
+ * device-scoped actions (per-account sign-out via `removeSession`); switching
46
+ * ALWAYS goes through `switchToAccount(accountId)`.
47
+ */
48
+ sessionId?: string;
49
+ /**
50
+ * Device-local refresh-cookie slot index (0..N-1), when the underlying
51
+ * `ClientSession` carries one (web silent-switch). Absent on native and for
52
+ * graph-only rows.
53
+ */
54
+ authuser?: number;
55
+ /** Whether this account is the currently-active one. */
56
+ isCurrent: boolean;
57
+ /** Whether this account is signed in on THIS device (has a `sessionId`). */
58
+ onDevice: boolean;
59
+ /**
60
+ * The caller's relationship to this account when it appears in the account
61
+ * graph: `self` (the caller's own personal account), `owner` (an org/project/
62
+ * bot the caller owns), or `member` (shared with the caller). Absent for an
63
+ * independent device sign-in that is NOT in the active account's graph.
64
+ */
65
+ relationship?: AccountRelationship;
66
+ /** Account classification (personal/organization/…). Cosmetic badge only. */
67
+ kind?: AccountKind;
68
+ /** Parent account id for 2-level tree grouping, or `null` for a root. */
69
+ parentAccountId?: string | null;
70
+ /**
71
+ * The caller's effective membership (role + permissions) in this account when
72
+ * it appears in the graph, or `null`/absent otherwise. Use `permissions` to
73
+ * gate per-account settings UI.
74
+ */
75
+ callerMembership?: AccountMember | null;
76
+ /** Friendly display name (never blank — falls back to a handle/sentinel). */
77
+ displayName: string;
78
+ /**
79
+ * Real account email, or `null` when the account genuinely has none. NEVER a
80
+ * synthesized `username@oxy.so` — a missing email falls back to the `@handle`
81
+ * secondary line.
82
+ */
83
+ email: string | null;
84
+ /** Resolved avatar thumbnail URL, or `undefined` when the account has no avatar. */
85
+ avatarUrl?: string;
86
+ /** Account's preferred Bloom color preset, or `null` when unset. */
87
+ color: string | null;
88
+ /** The underlying per-account user payload. */
89
+ user: SwitchableAccountUser;
90
+ }
91
+
92
+ export interface UseSwitchableAccountsResult {
93
+ /** Every switchable account (device sign-ins + linked graph accounts). */
94
+ accounts: SwitchableAccount[];
95
+ /** True until the per-device-account profile fetch settles. */
96
+ isLoading: boolean;
97
+ /** The currently-active session id (mirrors `useOxy().activeSessionId`). */
98
+ currentSessionId: string | null;
99
+ }
100
+
101
+ /** The minimal device-session shape {@link buildSwitchableAccounts} reads. */
102
+ export interface SwitchableSessionInput {
103
+ sessionId: string;
104
+ userId?: string;
105
+ authuser?: number;
106
+ }
107
+
108
+ export interface BuildSwitchableAccountsInput {
109
+ /** Device sessions projected from the server-authoritative `SessionClient`. */
110
+ sessions: SwitchableSessionInput[];
111
+ /** The currently-active session id. */
112
+ activeSessionId: string | null;
113
+ /** The freshest copy of the active account's user (`useOxy().user`). */
114
+ liveUser: User | null;
115
+ isAuthenticated: boolean;
116
+ /** The account graph under the active account (`useOxy().accounts`). */
117
+ graph: AccountNode[];
118
+ /** Per-device-account profiles resolved via `getUsersByIds()`, keyed by id. */
119
+ profilesById: Map<string, User>;
120
+ locale: string;
121
+ /** Resolves a file id to a thumbnail URL (bound to `getFileDownloadUrl`). */
122
+ resolveAvatarUrl: (avatar: string | null | undefined) => string | undefined;
123
+ }
124
+
125
+ /**
126
+ * Resolve which entries are the current account, robustly.
127
+ *
128
+ * Primary signal: `entry.sessionId === activeSessionId` — both are projected
129
+ * from the same `SessionClient` state, so this normally matches exactly.
130
+ * Fallbacks exist only to bridge the brief window between the two sequential
131
+ * `updateSessions` / `setActiveSessionId` calls in `OxyContext.syncFromClient`,
132
+ * applied only when the `sessionId` match found nothing AND the user is
133
+ * authenticated:
134
+ * 1. Match the live `user.id` against each entry's per-account user id.
135
+ * 2. If still nothing and there is exactly one account, mark that one current.
136
+ *
137
+ * At most ONE entry is ever marked current. Runs over DEVICE rows only (graph
138
+ * rows are never active — the active account is always a device session), so the
139
+ * single-account fallback counts device rows.
140
+ *
141
+ * Pure & side-effect free so it is unit-testable without rendering React.
142
+ */
143
+ export function markCurrentAccount(
144
+ accounts: SwitchableAccount[],
145
+ activeSessionId: string | null | undefined,
146
+ liveUserId: string | null | undefined,
147
+ isAuthenticated: boolean,
148
+ ): SwitchableAccount[] {
149
+ const bySession = accounts.map((account): SwitchableAccount => ({
150
+ ...account,
151
+ isCurrent: Boolean(activeSessionId) && account.sessionId === activeSessionId,
152
+ }));
153
+
154
+ if (bySession.some((account) => account.isCurrent) || !isAuthenticated) {
155
+ return bySession;
156
+ }
157
+
158
+ // No row matched by session id: fall back to the live user's id, marking at
159
+ // most the FIRST matching entry current (never more than one).
160
+ if (liveUserId) {
161
+ let matched = false;
162
+ const byUser = bySession.map((account): SwitchableAccount => {
163
+ if (!matched && account.user.id === liveUserId) {
164
+ matched = true;
165
+ return { ...account, isCurrent: true };
166
+ }
167
+ return account;
168
+ });
169
+ if (matched) {
170
+ return byUser;
171
+ }
172
+ }
173
+
174
+ // Authenticated, nothing matched, but there is exactly one account → it must
175
+ // be the current one.
176
+ if (bySession.length === 1) {
177
+ return [{ ...bySession[0], isCurrent: true }];
178
+ }
179
+
180
+ return bySession;
181
+ }
182
+
183
+ /**
184
+ * Pure union of device sign-ins and account-graph nodes into the flat
185
+ * {@link SwitchableAccount}[] the switchers render. Extracted so the merge /
186
+ * dedup semantics are unit-testable without rendering React.
187
+ *
188
+ * Order: device rows first (in session order, current flagged), then graph-only
189
+ * rows (in graph order). An account present as BOTH a device session and a graph
190
+ * node is deduped into ONE device row enriched with the graph metadata
191
+ * (relationship / kind / parent / membership).
192
+ */
193
+ export function buildSwitchableAccounts(input: BuildSwitchableAccountsInput): SwitchableAccount[] {
194
+ const {
195
+ sessions,
196
+ activeSessionId,
197
+ liveUser,
198
+ isAuthenticated,
199
+ graph,
200
+ profilesById,
201
+ locale,
202
+ resolveAvatarUrl,
203
+ } = input;
204
+
205
+ // Nothing is switchable when signed out — never surface a lingering device
206
+ // session or graph node once the user is unauthenticated.
207
+ if (!isAuthenticated) {
208
+ return [];
209
+ }
210
+
211
+ const toRow = (
212
+ accountUser: User,
213
+ opts: {
214
+ sessionId?: string;
215
+ authuser?: number;
216
+ relationship?: AccountRelationship;
217
+ kind?: AccountKind;
218
+ parentAccountId?: string | null;
219
+ callerMembership?: AccountMember | null;
220
+ },
221
+ ): SwitchableAccount => {
222
+ const displayName = getAccountDisplayName(accountUser, locale);
223
+ const handle = getAccountFallbackHandle(accountUser);
224
+ const secondaryHandle = handle ? `@${handle}` : null;
225
+ return {
226
+ accountId: accountUser.id?.toString() ?? '',
227
+ sessionId: opts.sessionId,
228
+ authuser: opts.authuser,
229
+ isCurrent: Boolean(opts.sessionId) && opts.sessionId === activeSessionId,
230
+ onDevice: Boolean(opts.sessionId),
231
+ relationship: opts.relationship,
232
+ kind: opts.kind,
233
+ parentAccountId: opts.parentAccountId,
234
+ callerMembership: opts.callerMembership,
235
+ displayName,
236
+ // Real email, or the `@handle` fallback (NEVER synthesized).
237
+ email: accountUser.email ?? secondaryHandle,
238
+ avatarUrl: resolveAvatarUrl(accountUser.avatar),
239
+ color: accountUser.color ?? null,
240
+ user: accountUser,
241
+ };
242
+ };
243
+
244
+ // --- Device rows ---
245
+ const built = sessions.flatMap((session): SwitchableAccount[] => {
246
+ const isCurrent = session.sessionId === activeSessionId;
247
+ // The active row always uses the live `user` — freshest available —
248
+ // regardless of whether the batch profile fetch has resolved yet.
249
+ const accountUser: User | undefined = isCurrent && liveUser
250
+ ? liveUser
251
+ : (session.userId ? profilesById.get(session.userId) : undefined);
252
+ if (!accountUser) {
253
+ return [];
254
+ }
255
+ return [toRow(accountUser, { sessionId: session.sessionId, authuser: session.authuser })];
256
+ });
257
+
258
+ const flagged = markCurrentAccount(built, activeSessionId, liveUser?.id ?? null, isAuthenticated);
259
+
260
+ // The signed-in user must ALWAYS be represented, even before the device
261
+ // session set has synced (e.g. immediately after cold boot). Synthesize a
262
+ // single current row from the live `useOxy().user` in that case.
263
+ const deviceRows = flagged.length === 0 && isAuthenticated && liveUser && activeSessionId
264
+ ? [toRow(liveUser, { sessionId: activeSessionId })]
265
+ : flagged;
266
+
267
+ // --- Merge graph nodes, deduping by account id ---
268
+ const byAccountId = new Map<string, SwitchableAccount>();
269
+ const order: string[] = [];
270
+ const remember = (row: SwitchableAccount): void => {
271
+ if (!row.accountId || byAccountId.has(row.accountId)) {
272
+ return;
273
+ }
274
+ byAccountId.set(row.accountId, row);
275
+ order.push(row.accountId);
276
+ };
277
+
278
+ for (const row of deviceRows) {
279
+ remember(row);
280
+ }
281
+
282
+ for (const node of graph) {
283
+ const existing = byAccountId.get(node.accountId);
284
+ if (existing) {
285
+ // On-device account that is ALSO in the graph: enrich the device row
286
+ // with graph metadata; keep its (freshest) profile + sessionId.
287
+ byAccountId.set(node.accountId, {
288
+ ...existing,
289
+ relationship: node.relationship,
290
+ kind: node.kind,
291
+ parentAccountId: node.parentAccountId,
292
+ callerMembership: node.callerMembership,
293
+ });
294
+ continue;
295
+ }
296
+ // Graph-only account (owned org / shared, not yet a device session).
297
+ remember(toRow(node.account, {
298
+ relationship: node.relationship,
299
+ kind: node.kind,
300
+ parentAccountId: node.parentAccountId,
301
+ callerMembership: node.callerMembership,
302
+ }));
303
+ }
304
+
305
+ return order.flatMap((id) => {
306
+ const row = byAccountId.get(id);
307
+ return row ? [row] : [];
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Resolve every account the signed-in user can switch into — device sign-ins
313
+ * AND linked graph accounts (owned orgs + shared-with-you) — as one flat list
314
+ * with real per-account name / email / avatar / color, deduped by account id.
315
+ *
316
+ * ## Data sources
317
+ *
318
+ * - Device sign-ins: `useOxy().sessions` — a `ClientSession[]` projected by
319
+ * `OxyContext.syncFromClient` from the server-authoritative `SessionClient`.
320
+ * Each session carries only `sessionId` + `userId`, so this hook additionally
321
+ * fetches every device account's profile via `oxyServices.getUsersByIds()`.
322
+ * The ACTIVE row always uses `useOxy().user` directly (the freshest copy).
323
+ * - Linked accounts: `useOxy().accounts` — the `AccountNode[]` graph the context
324
+ * loads from `GET /accounts`. Each node already embeds its `account` `User`
325
+ * document, so graph-only rows need no extra fetch.
326
+ *
327
+ * An account present as BOTH a device session and a graph node is deduped into
328
+ * ONE row (see {@link buildSwitchableAccounts}). EVERY row switches the same
329
+ * way — through `useOxy().switchToAccount(row.accountId)`.
330
+ *
331
+ * ## Error handling
332
+ *
333
+ * `getUsersByIds()` resolves to `[]` on a failed chunk (logged internally)
334
+ * rather than throwing. Device accounts whose profile could not be resolved are
335
+ * omitted until a subsequent fetch succeeds (except the active one, which never
336
+ * depends on this fetch); graph accounts are unaffected.
337
+ */
338
+ export function useSwitchableAccounts(): UseSwitchableAccountsResult {
339
+ const {
340
+ oxyServices,
341
+ sessions,
342
+ activeSessionId,
343
+ user,
344
+ isAuthenticated,
345
+ accounts: graph,
346
+ } = useOxy();
347
+ const { locale } = useI18n();
348
+
349
+ // Every distinct account id carried by the device's session set, sorted for
350
+ // a stable query key regardless of session ordering.
351
+ const accountIds = useMemo<string[]>(() => {
352
+ const ids = new Set<string>();
353
+ for (const session of sessions ?? []) {
354
+ if (session.userId) {
355
+ ids.add(session.userId);
356
+ }
357
+ }
358
+ return Array.from(ids).sort();
359
+ }, [sessions]);
360
+
361
+ const profilesQuery = useQuery({
362
+ queryKey: queryKeys.users.list(accountIds),
363
+ queryFn: () => oxyServices.getUsersByIds(accountIds),
364
+ enabled: isAuthenticated && accountIds.length > 0,
365
+ staleTime: 5 * 60 * 1000, // 5 minutes — matches useUserProfile's convention
366
+ gcTime: 30 * 60 * 1000, // 30 minutes
367
+ });
368
+
369
+ const profilesById = useMemo<Map<string, User>>(() => {
370
+ const map = new Map<string, User>();
371
+ for (const profile of profilesQuery.data ?? []) {
372
+ map.set(profile.id, profile);
373
+ }
374
+ return map;
375
+ }, [profilesQuery.data]);
376
+
377
+ const accounts = useMemo<SwitchableAccount[]>(
378
+ () => buildSwitchableAccounts({
379
+ sessions: sessions ?? [],
380
+ activeSessionId: activeSessionId ?? null,
381
+ liveUser: user ?? null,
382
+ isAuthenticated,
383
+ graph: graph ?? [],
384
+ profilesById,
385
+ locale,
386
+ resolveAvatarUrl: (avatar) =>
387
+ (avatar ? oxyServices.getFileDownloadUrl(avatar, 'thumb') : undefined),
388
+ }),
389
+ [sessions, activeSessionId, user, isAuthenticated, graph, profilesById, locale, oxyServices],
390
+ );
391
+
392
+ return {
393
+ accounts,
394
+ isLoading: isAuthenticated && accountIds.length > 0 && profilesQuery.isLoading,
395
+ currentSessionId: activeSessionId ?? null,
396
+ };
397
+ }
@@ -1,237 +1,23 @@
1
1
  /**
2
- * Web SSO Hook
3
- *
4
- * Handles cross-domain SSO for web apps using FedCM (Federated Credential Management).
5
- *
6
- * FedCM is the modern, privacy-preserving standard for cross-domain identity federation.
7
- * It works across completely different TLDs (alia.onl, mention.earth, homiio.com, etc.)
8
- * without relying on third-party cookies.
9
- *
10
- * For browsers without FedCM support, users will need to click a sign-in button
11
- * which triggers redirect authentication.
12
- *
13
- * This is called automatically by OxyContext on web platforms.
14
- *
15
- * @see https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API
2
+ * Platform detection helper for the web auth/session-sync surfaces.
3
+ *
4
+ * This module previously hosted a FedCM-based `useWebSSO` hook (Federated
5
+ * Credential Management — Chrome-only). It was removed: FedCM is no longer
6
+ * used anywhere in the client sign-in/cold-boot path (see `CrossDomainAuth`'s
7
+ * doc comment in `@oxyhq/core` for the production sign-in loop that motivated
8
+ * the removal). Cross-domain silent SSO is owned entirely by the
9
+ * `silent-iframe` cold-boot step (per-apex `/auth/silent`) plus the terminal
10
+ * `/sso` bounce in `OxyContext`.
11
+ *
12
+ * `isWebBrowser` is kept here (rather than moved) so every existing consumer
13
+ * import path stays valid.
16
14
  */
17
15
 
18
- import { useEffect, useRef, useCallback } from 'react';
19
- import type { OxyServices } from '@oxyhq/core';
20
- import type { SessionLoginResponse } from '@oxyhq/core';
21
- import { buildSilentGuardKey } from '../../utils/silentGuardKey';
22
-
23
- interface UseWebSSOOptions {
24
- oxyServices: OxyServices;
25
- onSessionFound: (session: SessionLoginResponse) => Promise<void>;
26
- onSSOUnavailable?: () => void;
27
- onError?: (error: Error) => void;
28
- enabled?: boolean;
29
- }
30
-
31
- interface UseWebSSOResult {
32
- /** Manually trigger SSO check */
33
- checkSSO: () => Promise<SessionLoginResponse | null>;
34
- /** Trigger interactive FedCM sign-in (shows browser UI) */
35
- signInWithFedCM: () => Promise<SessionLoginResponse | null>;
36
- /** Whether SSO check is in progress */
37
- isChecking: boolean;
38
- /** Whether FedCM is supported in this browser */
39
- isFedCMSupported: boolean;
40
- }
41
-
42
- /**
43
- * Module-level guard tracking which (origin + API) signatures have already
44
- * had a silent SSO attempt this page load.
45
- *
46
- * A per-component `useRef` guard resets whenever the provider remounts (route
47
- * churn, StrictMode double-invoke, error-boundary recovery), which previously
48
- * allowed silent SSO to re-fire and — combined with a routing redirect loop —
49
- * produced an accelerating `navigator.credentials.get` retry storm. Keying the
50
- * guard on a stable signature instead of the component instance makes silent
51
- * SSO fire EXACTLY ONCE per page load regardless of how many times the
52
- * provider mounts. The set is intentionally never cleared: a fresh page load
53
- * (the only thing that can change the answer) starts a fresh module scope.
54
- */
55
- const silentSSOAttempted = new Set<string>();
56
-
57
- /**
58
- * Build a stable signature for the silent-SSO run-once guard. Two providers
59
- * pointed at the same API from the same origin share one attempt.
60
- */
61
- function ssoSignature(oxyServices: OxyServices): string {
62
- // Shared with `OxyContext.silentColdBootKey`. `buildSilentGuardKey` reads
63
- // `window.location.origin` behind a guard that also verifies
64
- // `window.location` exists — React Native aliases a global `window` with NO
65
- // `window.location`, so a `typeof window`-only check would throw
66
- // `Cannot read property 'origin' of undefined` off-browser.
67
- return buildSilentGuardKey(() => oxyServices.getBaseURL?.());
68
- }
69
-
70
16
  /**
71
17
  * Check if we're running in a web browser environment (not React Native)
72
18
  */
73
- function isWebBrowser(): boolean {
19
+ export function isWebBrowser(): boolean {
74
20
  return typeof window !== 'undefined' &&
75
21
  typeof document !== 'undefined' &&
76
22
  typeof document.documentElement !== 'undefined';
77
23
  }
78
-
79
- /**
80
- * Check if we're on the identity provider domain (where FedCM would authenticate against itself)
81
- * Compares against config.authWebUrl if set, otherwise defaults to auth.oxy.so
82
- */
83
- function isIdentityProvider(authWebUrl?: string): boolean {
84
- if (!isWebBrowser()) return false;
85
- const hostname = window.location.hostname;
86
- let idpHostname = 'auth.oxy.so';
87
- if (authWebUrl) {
88
- try { idpHostname = new URL(authWebUrl).hostname; } catch { /* malformed URL, use default */ }
89
- }
90
- return hostname === idpHostname;
91
- }
92
-
93
- /**
94
- * Hook for automatic cross-domain web SSO
95
- *
96
- * Uses FedCM (Federated Credential Management) - the modern browser-native
97
- * identity federation API. This is the same technology that powers
98
- * Google's cross-domain SSO (YouTube, Gmail, Maps, etc.).
99
- *
100
- * Key benefits:
101
- * - Works across different TLDs (alia.onl ↔ mention.earth ↔ homiio.com)
102
- * - No third-party cookies required
103
- * - Privacy-preserving (browser mediates identity, IdP can't track)
104
- * - Automatic silent sign-in after initial authentication
105
- *
106
- * For browsers without FedCM (Firefox, older browsers), automatic SSO
107
- * is not possible. Users will see a sign-in button instead.
108
- */
109
- export function useWebSSO({
110
- oxyServices,
111
- onSessionFound,
112
- onSSOUnavailable,
113
- onError,
114
- enabled = true,
115
- }: UseWebSSOOptions): UseWebSSOResult {
116
- const isCheckingRef = useRef(false);
117
- const hasCheckedRef = useRef(false);
118
-
119
- // Check FedCM support once
120
- const fedCMSupported = isWebBrowser() && oxyServices.isFedCMSupported?.();
121
- const authWebUrl = oxyServices.config?.authWebUrl;
122
-
123
- const checkSSO = useCallback(async (): Promise<SessionLoginResponse | null> => {
124
- if (!isWebBrowser() || isCheckingRef.current) {
125
- return null;
126
- }
127
-
128
- // Don't use FedCM on the auth domain itself - it would authenticate against itself
129
- if (isIdentityProvider(authWebUrl)) {
130
- onSSOUnavailable?.();
131
- return null;
132
- }
133
-
134
- // FedCM is the only reliable cross-domain SSO mechanism
135
- if (!fedCMSupported) {
136
- onSSOUnavailable?.();
137
- return null;
138
- }
139
-
140
- isCheckingRef.current = true;
141
-
142
- try {
143
- const session = await oxyServices.silentSignInWithFedCM?.();
144
-
145
- if (session) {
146
- await onSessionFound(session);
147
- return session;
148
- }
149
-
150
- onSSOUnavailable?.();
151
- return null;
152
- } catch (error) {
153
- onSSOUnavailable?.();
154
- onError?.(error instanceof Error ? error : new Error(String(error)));
155
- return null;
156
- } finally {
157
- isCheckingRef.current = false;
158
- }
159
- }, [oxyServices, onSessionFound, onSSOUnavailable, onError, fedCMSupported, authWebUrl]);
160
-
161
- /**
162
- * Trigger interactive FedCM sign-in
163
- * This shows the browser's native "Sign in with Oxy" prompt.
164
- * Use this when silent mediation fails (user hasn't previously consented).
165
- */
166
- const signInWithFedCM = useCallback(async (): Promise<SessionLoginResponse | null> => {
167
- if (!isWebBrowser() || isCheckingRef.current) {
168
- return null;
169
- }
170
-
171
- if (!fedCMSupported) {
172
- onError?.(new Error('FedCM is not supported in this browser'));
173
- return null;
174
- }
175
-
176
- isCheckingRef.current = true;
177
-
178
- try {
179
- const session = await oxyServices.signInWithFedCM?.();
180
-
181
- if (session) {
182
- await onSessionFound(session);
183
- return session;
184
- }
185
-
186
- return null;
187
- } catch (error) {
188
- onError?.(error instanceof Error ? error : new Error(String(error)));
189
- return null;
190
- } finally {
191
- isCheckingRef.current = false;
192
- }
193
- }, [oxyServices, onSessionFound, onError, fedCMSupported]);
194
-
195
- // Auto-check SSO on mount (web only, FedCM only, not on auth domain).
196
- //
197
- // Run-once is enforced by TWO guards:
198
- // 1. `hasCheckedRef` — cheap per-instance fast-path so effect re-runs
199
- // (from changing deps) within one mount never re-fire.
200
- // 2. `silentSSOAttempted` — module-level, survives remounts/StrictMode so
201
- // silent SSO fires exactly once per page load even if the provider
202
- // unmounts and remounts.
203
- useEffect(() => {
204
- if (!enabled || !isWebBrowser() || hasCheckedRef.current || isIdentityProvider(authWebUrl)) {
205
- if (isIdentityProvider(authWebUrl)) {
206
- onSSOUnavailable?.();
207
- }
208
- return;
209
- }
210
-
211
- const signature = ssoSignature(oxyServices);
212
- if (silentSSOAttempted.has(signature)) {
213
- // Already attempted this page load (e.g. before a remount) — do not
214
- // re-fire. Mark the local fast-path too so subsequent re-renders skip.
215
- hasCheckedRef.current = true;
216
- return;
217
- }
218
-
219
- hasCheckedRef.current = true;
220
- silentSSOAttempted.add(signature);
221
-
222
- if (fedCMSupported) {
223
- checkSSO();
224
- } else {
225
- onSSOUnavailable?.();
226
- }
227
- }, [enabled, checkSSO, fedCMSupported, onSSOUnavailable, oxyServices, authWebUrl]);
228
-
229
- return {
230
- checkSSO,
231
- signInWithFedCM,
232
- isChecking: isCheckingRef.current,
233
- isFedCMSupported: fedCMSupported,
234
- };
235
- }
236
-
237
- export { isWebBrowser };
@@ -33,12 +33,12 @@ import { isCrossApexWeb } from '../../utils/crossApex';
33
33
 
34
34
  const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) => {
35
35
  const bloomTheme = useTheme();
36
- const { oxyServices, switchSession, clientId } = useOxy();
36
+ const { oxyServices, handleWebSession, clientId } = useOxy();
37
37
 
38
38
  const { qrData, qrPayload, isLoading, error, isWaiting, openAuthApproval, openSameDeviceApproval, retry } = useOxyAuthSession(
39
39
  oxyServices,
40
40
  clientId,
41
- switchSession,
41
+ handleWebSession,
42
42
  { onSignedIn: onAuthenticated },
43
43
  );
44
44