@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
@@ -1,13 +1,13 @@
1
1
  import type React from 'react';
2
- import { useCallback, useEffect, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
3
  import {
4
4
  View,
5
5
  Pressable,
6
6
  Modal,
7
7
  ScrollView,
8
8
  ActivityIndicator,
9
- StyleSheet,
10
9
  Platform,
10
+ StyleSheet,
11
11
  type ViewStyle,
12
12
  } from 'react-native';
13
13
  import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -19,7 +19,7 @@ import { Divider } from '@oxyhq/bloom/divider';
19
19
  import { isDev, logger as loggerUtil } from '@oxyhq/core';
20
20
  import { useOxy } from '../context/OxyContext';
21
21
  import { useI18n } from '../hooks/useI18n';
22
- import { useDeviceAccounts } from '../hooks/useDeviceAccounts';
22
+ import { useSwitchableAccounts, type SwitchableAccount } from '../hooks/useSwitchableAccounts';
23
23
 
24
24
  const isWeb = Platform.OS === 'web';
25
25
 
@@ -58,58 +58,67 @@ export interface ProfileMenuProps {
58
58
  onBeforeSessionChange?: () => void | Promise<void>;
59
59
  }
60
60
 
61
- /** Everything `ProfileMenuContent` needs, minus the `open` flag (always open). */
62
- type ProfileMenuContentProps = Omit<ProfileMenuProps, 'open'>;
63
-
64
61
  /**
65
- * Clean device-account switcher, modeled on the inbox `AccountMenu` and styled
66
- * with react-native `StyleSheet` + the Bloom theme (via `useTheme`) so it
67
- * renders in EVERY consumer regardless of NativeWind. Lists every account
68
- * signed in on this device (from {@link useDeviceAccounts}); tapping a row
69
- * switches, and each inactive row carries a sign-out icon. Below the list:
70
- * Add account, Manage account, optional View profile, and Sign out of all.
62
+ * Clean account switcher, written with react-native `StyleSheet` + Bloom
63
+ * theme colors (the package convention no NativeWind, which is only an
64
+ * optional peer and absent in the primary `accounts` consumer). Lists EVERY
65
+ * switchable account from {@link useSwitchableAccounts}
66
+ * device sign-ins AND linked graph accounts (owned orgs + shared-with-you)
67
+ * and routes EVERY switch through the context's single
68
+ * `switchToAccount(accountId)` dispatcher (there is no separate device-only
69
+ * path). Each on-device inactive row carries a per-account sign-out icon;
70
+ * graph-only rows carry a role badge and indent under their parent. Below the
71
+ * list: Add account, Manage account, optional View profile, and Sign out of all.
71
72
  *
72
- * Heavy hooks (device accounts / refresh-all, Bloom dialog/toast controls, the
73
- * escape-key listener) live here so they run ONLY while the menu is open — the
74
- * outer {@link ProfileMenu} mounts this only when `open`. This component may
75
- * therefore assume it is always open.
73
+ * Renders as an upward-opening popover anchored to the trigger on web, and a
74
+ * bottom-sheet style modal on native.
76
75
  */
77
- const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
76
+ const ProfileMenu: React.FC<ProfileMenuProps> = ({
77
+ open,
78
78
  onClose,
79
+ anchor,
79
80
  onNavigateManage,
80
81
  onAddAccount,
81
82
  onNavigateProfile,
82
83
  onBeforeSessionChange,
83
84
  }) => {
84
85
  const {
85
- activeSessionId,
86
- switchSession,
86
+ switchToAccount,
87
87
  logoutAll,
88
88
  removeSession,
89
89
  } = useOxy();
90
90
  const { t } = useI18n();
91
91
  const { colors } = useTheme();
92
92
 
93
- const { accounts } = useDeviceAccounts();
93
+ const { accounts } = useSwitchableAccounts();
94
94
 
95
- const [busySessionId, setBusySessionId] = useState<string | null>(null);
95
+ const [busyAccountId, setBusyAccountId] = useState<string | null>(null);
96
96
  const [removingSessionId, setRemovingSessionId] = useState<string | null>(null);
97
97
  const [signingOutAll, setSigningOutAll] = useState(false);
98
98
 
99
99
  const signOutAllDialog = useDialogControl();
100
100
 
101
- const isSwitching = busySessionId !== null;
101
+ const isSwitching = busyAccountId !== null;
102
102
  const actionDisabled = isSwitching || removingSessionId !== null || signingOutAll;
103
103
 
104
- // Switch to a non-active account through the SDK's canonical switch path.
105
- const handleSwitch = useCallback(async (sessionId: string) => {
106
- if (sessionId === activeSessionId || busySessionId) {
104
+ // Account ids currently in the list used to decide whether a graph-only
105
+ // row is a CHILD (its parent is also shown) so it can be indented.
106
+ const listedAccountIds = useMemo(
107
+ () => new Set(accounts.map((account) => account.accountId)),
108
+ [accounts],
109
+ );
110
+
111
+ // The ONE uniform switch path: every row (device or linked) routes through
112
+ // `switchToAccount(accountId)`. It reuses the device session when the account
113
+ // is already signed in here, and mints one only on first entry.
114
+ const handleSwitch = useCallback(async (account: SwitchableAccount) => {
115
+ if (account.isCurrent || busyAccountId) {
107
116
  return;
108
117
  }
109
- setBusySessionId(sessionId);
118
+ setBusyAccountId(account.accountId);
110
119
  try {
111
120
  await onBeforeSessionChange?.();
112
- await switchSession(sessionId);
121
+ await switchToAccount(account.accountId);
113
122
  toast.success(t('accountSwitcher.toasts.switchSuccess') || 'Switched account');
114
123
  onClose();
115
124
  } catch (error) {
@@ -118,14 +127,14 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
118
127
  }
119
128
  toast.error(t('accountSwitcher.toasts.switchFailed') || 'Failed to switch account');
120
129
  } finally {
121
- setBusySessionId(null);
130
+ setBusyAccountId(null);
122
131
  }
123
- }, [activeSessionId, busySessionId, switchSession, t, onClose, onBeforeSessionChange]);
132
+ }, [busyAccountId, switchToAccount, t, onClose, onBeforeSessionChange]);
124
133
 
125
134
  // Sign out a specific inactive account without switching/clearing the active
126
135
  // one. The menu stays open so the user can keep managing accounts.
127
136
  const handleRemove = useCallback(async (sessionId: string) => {
128
- if (sessionId === activeSessionId || removingSessionId) {
137
+ if (removingSessionId) {
129
138
  return;
130
139
  }
131
140
  setRemovingSessionId(sessionId);
@@ -138,7 +147,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
138
147
  } finally {
139
148
  setRemovingSessionId(null);
140
149
  }
141
- }, [activeSessionId, removingSessionId, removeSession, t]);
150
+ }, [removingSessionId, removeSession, t]);
142
151
 
143
152
  const performSignOutAll = useCallback(async () => {
144
153
  if (signingOutAll) {
@@ -158,10 +167,9 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
158
167
  }
159
168
  }, [signingOutAll, logoutAll, t, onClose, onBeforeSessionChange]);
160
169
 
161
- // Escape-to-close (web only). This component only mounts while open, so the
162
- // listener is attached exactly for the menu's lifetime.
170
+ // Escape-to-close (web only).
163
171
  useEffect(() => {
164
- if (!isWeb || typeof document === 'undefined') {
172
+ if (!open || !isWeb || typeof document === 'undefined') {
165
173
  return undefined;
166
174
  }
167
175
  const onKey = (event: KeyboardEvent) => {
@@ -172,30 +180,74 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
172
180
  };
173
181
  document.addEventListener('keydown', onKey, true);
174
182
  return () => document.removeEventListener('keydown', onKey, true);
175
- }, [onClose]);
183
+ }, [open, onClose]);
176
184
 
177
- return (
178
- <>
185
+ if (!open) {
186
+ return null;
187
+ }
188
+
189
+ // Web: anchor the panel to the measured trigger. When the anchor pins `top`
190
+ // the panel opens DOWNWARD from the trigger; otherwise it pins `bottom` and
191
+ // opens UPWARD (the default footer behavior). With no anchor captured, fall
192
+ // back to a bottom-left placement. Native ignores this (the panel docks to
193
+ // the bottom via the overlay's flex-end).
194
+ const panelAnchorStyle: ViewStyle | undefined = isWeb
195
+ ? {
196
+ position: 'absolute',
197
+ width: MENU_WIDTH,
198
+ left: anchor?.left ?? 8,
199
+ ...(typeof anchor?.top === 'number'
200
+ ? { top: anchor.top }
201
+ : { bottom: anchor?.bottom ?? 8 }),
202
+ }
203
+ : undefined;
204
+
205
+ const content = (
206
+ <Pressable
207
+ // Swallow taps inside the panel so they never reach the overlay's
208
+ // outside-tap-to-close handler.
209
+ onPress={() => undefined}
210
+ style={[
211
+ isWeb ? styles.panelWeb : styles.panelNative,
212
+ { backgroundColor: colors.background },
213
+ isWeb ? { borderColor: colors.border } : null,
214
+ panelAnchorStyle,
215
+ styles.panelBounds,
216
+ styles.shadow,
217
+ ]}
218
+ accessibilityRole="menu"
219
+ accessibilityLabel={t('accountMenu.label') || 'Account menu'}
220
+ >
179
221
  <ScrollView
180
222
  style={styles.scroll}
181
223
  contentContainerStyle={styles.scrollContent}
182
224
  showsVerticalScrollIndicator={false}
183
225
  >
184
- {/* 1) Device accountsactive first (checkmark), others switchable. */}
226
+ {/* 1) Every switchable account device sign-ins AND linked graph
227
+ accounts. Active first (checkmark); on-device rows carry a
228
+ per-account sign-out icon; graph-only rows show a role badge
229
+ and indent under their parent. Every row switches through the
230
+ SAME `switchToAccount(accountId)` path. */}
185
231
  {accounts.map((account) => {
186
232
  const isActive = account.isCurrent;
187
- const isBusy = busySessionId === account.sessionId;
188
- const isRemoving = removingSessionId === account.sessionId;
233
+ const isBusy = busyAccountId === account.accountId;
234
+ const sessionId = account.sessionId;
235
+ const isRemoving = sessionId ? removingSessionId === sessionId : false;
236
+ const isChild = Boolean(
237
+ account.parentAccountId && listedAccountIds.has(account.parentAccountId),
238
+ );
239
+ const role = account.callerMembership?.role;
189
240
  return (
190
241
  <Pressable
191
- key={`account-${account.sessionId}`}
242
+ key={`account-${account.accountId}`}
192
243
  accessibilityRole="menuitem"
193
244
  accessibilityLabel={account.displayName}
194
245
  accessibilityState={{ selected: isActive }}
195
- onPress={() => handleSwitch(account.sessionId)}
246
+ onPress={() => handleSwitch(account)}
196
247
  disabled={isActive || isBusy || isSwitching}
197
248
  style={[
198
249
  styles.accountRow,
250
+ isChild && styles.childRow,
199
251
  isActive && { backgroundColor: colors.backgroundSecondary },
200
252
  isSwitching && !isActive && styles.rowDimmed,
201
253
  ]}
@@ -205,18 +257,28 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
205
257
  uri={account.avatarUrl}
206
258
  variant="thumb"
207
259
  name={account.displayName}
208
- size={isActive ? 40 : 32}
260
+ size={isActive ? 40 : isChild ? 28 : 32}
209
261
  />
210
262
  <View style={styles.accountInfo}>
211
- <Text
212
- style={[
213
- isActive ? styles.accountNameActive : styles.accountName,
214
- { color: colors.text },
215
- ]}
216
- numberOfLines={1}
217
- >
218
- {account.displayName}
219
- </Text>
263
+ <View style={styles.nameRow}>
264
+ <Text
265
+ style={[
266
+ styles.accountName,
267
+ { color: colors.text },
268
+ isActive && styles.accountNameActive,
269
+ ]}
270
+ numberOfLines={1}
271
+ >
272
+ {account.displayName}
273
+ </Text>
274
+ {role ? (
275
+ <View style={[styles.roleBadge, { backgroundColor: colors.card }]}>
276
+ <Text style={[styles.roleBadgeText, { color: colors.textSecondary }]}>
277
+ {t(`accounts.roles.${role}.label`) || role}
278
+ </Text>
279
+ </View>
280
+ ) : null}
281
+ </View>
220
282
  {account.email ? (
221
283
  <Text
222
284
  style={[styles.accountEmail, { color: colors.textSecondary }]}
@@ -232,14 +294,14 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
232
294
  <MaterialCommunityIcons name="check" size={20} color={colors.primary} />
233
295
  ) : isRemoving ? (
234
296
  <ActivityIndicator color={colors.textSecondary} size="small" />
235
- ) : (
297
+ ) : sessionId ? (
236
298
  <Pressable
237
299
  accessibilityRole="button"
238
300
  accessibilityLabel={
239
301
  t('accountMenu.signOutAccount', { name: account.displayName })
240
302
  || `Sign out ${account.displayName}`
241
303
  }
242
- onPress={() => handleRemove(account.sessionId)}
304
+ onPress={() => handleRemove(sessionId)}
243
305
  disabled={actionDisabled}
244
306
  hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
245
307
  style={styles.rowSignOutButton}
@@ -250,7 +312,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
250
312
  color={colors.textSecondary}
251
313
  />
252
314
  </Pressable>
253
- )}
315
+ ) : null}
254
316
  </Pressable>
255
317
  );
256
318
  })}
@@ -315,7 +377,7 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
315
377
  accessibilityLabel={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
316
378
  onPress={() => signOutAllDialog.open()}
317
379
  disabled={actionDisabled}
318
- style={[styles.signOutAllRow, actionDisabled && styles.rowDimmed]}
380
+ style={[styles.actionRow, actionDisabled && styles.rowDimmed]}
319
381
  >
320
382
  {signingOutAll ? (
321
383
  <ActivityIndicator color={colors.error} size="small" />
@@ -327,63 +389,8 @@ const ProfileMenuContent: React.FC<ProfileMenuContentProps> = ({
327
389
  </Text>
328
390
  </Pressable>
329
391
  </ScrollView>
330
-
331
- <Dialog
332
- control={signOutAllDialog}
333
- title={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
334
- description={t('common.confirms.signOutAll') || 'Are you sure you want to sign out of all accounts?'}
335
- actions={[
336
- {
337
- label: t('accountMenu.signOutAll') || 'Sign out of all accounts',
338
- color: 'destructive',
339
- onPress: performSignOutAll,
340
- },
341
- { label: t('common.cancel') || 'Cancel', color: 'cancel' },
342
- ]}
343
- />
344
- </>
392
+ </Pressable>
345
393
  );
346
- };
347
-
348
- /**
349
- * Public wrapper. The RN `Modal` shell is ALWAYS mounted (only `visible` toggles)
350
- * so the react-native-web portal is created up front — mounting a web `Modal`
351
- * already-`visible` is fragile (the first interaction can be eaten / the button
352
- * "does nothing"). Only the heavy {@link ProfileMenuContent} mounts when open,
353
- * keeping the account hooks off the render path when the menu is closed. On
354
- * native that closed render is `<Modal visible={false}>{null}</Modal>` → no heavy
355
- * hooks run (preserving the native-drawer crash fix).
356
- *
357
- * This outer component calls ONLY light, always-safe hooks (`useTheme`,
358
- * `useI18n` for the overlay a11y label) and computes the panel/overlay layout.
359
- */
360
- const ProfileMenu: React.FC<ProfileMenuProps> = ({
361
- open,
362
- onClose,
363
- anchor,
364
- onNavigateManage,
365
- onAddAccount,
366
- onNavigateProfile,
367
- onBeforeSessionChange,
368
- }) => {
369
- const { t } = useI18n();
370
- const { colors } = useTheme();
371
-
372
- // Web: anchor the panel to the measured trigger. When the anchor pins `top`
373
- // the panel opens DOWNWARD from the trigger; otherwise it pins `bottom` and
374
- // opens UPWARD (the default footer behavior). With no anchor captured, fall
375
- // back to a bottom-left placement. Native ignores this (the panel docks to
376
- // the bottom via the overlay's flex-end).
377
- const panelAnchorStyle: ViewStyle | undefined = isWeb
378
- ? {
379
- position: 'absolute',
380
- width: MENU_WIDTH,
381
- left: anchor?.left ?? 8,
382
- ...(typeof anchor?.top === 'number'
383
- ? { top: anchor.top }
384
- : { bottom: anchor?.bottom ?? 8 }),
385
- }
386
- : undefined;
387
394
 
388
395
  return (
389
396
  <Modal
@@ -398,33 +405,22 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({
398
405
  onPress={onClose}
399
406
  style={isWeb ? styles.webOverlay : styles.nativeOverlay}
400
407
  >
401
- <Pressable
402
- // Swallow taps inside the panel so they never reach the overlay's
403
- // outside-tap-to-close handler.
404
- onPress={() => undefined}
405
- style={[
406
- isWeb ? styles.panelWeb : styles.panelNative,
407
- { backgroundColor: colors.background },
408
- isWeb && { borderColor: colors.border },
409
- panelAnchorStyle,
410
- styles.panelBounds,
411
- styles.shadow,
412
- ]}
413
- accessibilityRole="menu"
414
- accessibilityLabel={t('accountMenu.label') || 'Account menu'}
415
- >
416
- {open ? (
417
- <ProfileMenuContent
418
- onClose={onClose}
419
- anchor={anchor}
420
- onNavigateManage={onNavigateManage}
421
- onAddAccount={onAddAccount}
422
- onNavigateProfile={onNavigateProfile}
423
- onBeforeSessionChange={onBeforeSessionChange}
424
- />
425
- ) : null}
426
- </Pressable>
408
+ {content}
427
409
  </Pressable>
410
+
411
+ <Dialog
412
+ control={signOutAllDialog}
413
+ title={t('accountMenu.signOutAll') || 'Sign out of all accounts'}
414
+ description={t('common.confirms.signOutAll') || 'Are you sure you want to sign out of all accounts?'}
415
+ actions={[
416
+ {
417
+ label: t('accountMenu.signOutAll') || 'Sign out of all accounts',
418
+ color: 'destructive',
419
+ onPress: performSignOutAll,
420
+ },
421
+ { label: t('common.cancel') || 'Cancel', color: 'cancel' },
422
+ ]}
423
+ />
428
424
  </Modal>
429
425
  );
430
426
  };
@@ -451,7 +447,8 @@ const ActionRow: React.FC<{
451
447
  );
452
448
 
453
449
  const styles = StyleSheet.create({
454
- // Overlay (`flex-1 relative` web / `flex-1 justify-end` + scrim native).
450
+ // Overlay: web is a positioning context for the anchored panel; native dims
451
+ // the backdrop and docks the sheet to the bottom.
455
452
  webOverlay: {
456
453
  flex: 1,
457
454
  position: 'relative',
@@ -461,24 +458,23 @@ const styles = StyleSheet.create({
461
458
  justifyContent: 'flex-end',
462
459
  backgroundColor: 'rgba(0,0,0,0.32)',
463
460
  },
464
- // Panel shell — web popover (`overflow-hidden rounded-2xl border`).
461
+ // Panel shell — web popover / native bottom sheet.
465
462
  panelWeb: {
466
463
  overflow: 'hidden',
467
464
  borderRadius: 16,
468
465
  borderWidth: 1,
469
466
  },
470
- // Panel shell — native bottom sheet (`overflow-hidden rounded-t-3xl pb-3`).
471
467
  panelNative: {
472
468
  overflow: 'hidden',
473
469
  borderTopLeftRadius: 24,
474
470
  borderTopRightRadius: 24,
475
471
  paddingBottom: 12,
476
472
  },
477
- // Shared `maxHeight: '85%'` cap applied to both panel variants.
473
+ // Shared height cap applied to both panel variants.
478
474
  panelBounds: {
479
475
  maxHeight: '85%',
480
476
  },
481
- // The panel's drop shadow dynamic elevation with no class equivalent.
477
+ // The panel's drop shadow (dynamic elevation).
482
478
  shadow: {
483
479
  shadowColor: '#000',
484
480
  shadowOpacity: 0.18,
@@ -486,14 +482,12 @@ const styles = StyleSheet.create({
486
482
  shadowOffset: { width: 0, height: 8 },
487
483
  elevation: 12,
488
484
  },
489
- // Scroll region (`grow-0` + `py-1` content).
490
485
  scroll: {
491
486
  flexGrow: 0,
492
487
  },
493
488
  scrollContent: {
494
489
  paddingVertical: 4,
495
490
  },
496
- // Account row (`flex-row items-center gap-3 px-4 py-2.5`).
497
491
  accountRow: {
498
492
  flexDirection: 'row',
499
493
  alignItems: 'center',
@@ -501,28 +495,41 @@ const styles = StyleSheet.create({
501
495
  paddingHorizontal: 16,
502
496
  paddingVertical: 10,
503
497
  },
504
- // `opacity-40` dim applied to disabled / non-active-while-switching rows.
498
+ childRow: {
499
+ paddingLeft: 40,
500
+ },
505
501
  rowDimmed: {
506
502
  opacity: 0.4,
507
503
  },
508
- // Identity block (`min-w-0 flex-1`).
509
504
  accountInfo: {
510
505
  flex: 1,
511
506
  minWidth: 0,
512
507
  },
513
- // Inactive account name (`font-medium`).
508
+ nameRow: {
509
+ flexDirection: 'row',
510
+ alignItems: 'center',
511
+ gap: 6,
512
+ },
514
513
  accountName: {
515
514
  fontWeight: '500',
515
+ flexShrink: 1,
516
516
  },
517
- // Active account name (`font-semibold`).
518
517
  accountNameActive: {
519
518
  fontWeight: '600',
520
519
  },
521
- // Secondary email line (`text-xs`).
522
520
  accountEmail: {
523
521
  fontSize: 12,
524
522
  },
525
- // Per-row sign-out button (`h-7 w-7 items-center justify-center rounded-full opacity-60`).
523
+ roleBadge: {
524
+ paddingHorizontal: 6,
525
+ paddingVertical: 1,
526
+ borderRadius: 8,
527
+ },
528
+ roleBadgeText: {
529
+ fontSize: 10,
530
+ fontWeight: '600',
531
+ textTransform: 'capitalize',
532
+ },
526
533
  rowSignOutButton: {
527
534
  width: 28,
528
535
  height: 28,
@@ -531,7 +538,6 @@ const styles = StyleSheet.create({
531
538
  borderRadius: 9999,
532
539
  opacity: 0.6,
533
540
  },
534
- // Switching indicator (`flex-row items-center justify-center gap-2 py-2`).
535
541
  switchingRow: {
536
542
  flexDirection: 'row',
537
543
  alignItems: 'center',
@@ -543,7 +549,6 @@ const styles = StyleSheet.create({
543
549
  fontSize: 12,
544
550
  fontWeight: '500',
545
551
  },
546
- // Bottom action rows (`flex-row items-center gap-3 px-4 py-3`).
547
552
  actionRow: {
548
553
  flexDirection: 'row',
549
554
  alignItems: 'center',
@@ -551,15 +556,6 @@ const styles = StyleSheet.create({
551
556
  paddingHorizontal: 16,
552
557
  paddingVertical: 12,
553
558
  },
554
- // Sign-out-of-all row shares the action-row geometry.
555
- signOutAllRow: {
556
- flexDirection: 'row',
557
- alignItems: 'center',
558
- gap: 12,
559
- paddingHorizontal: 16,
560
- paddingVertical: 12,
561
- },
562
- // Action / sign-out label (`font-medium`).
563
559
  actionText: {
564
560
  fontWeight: '500',
565
561
  },
@@ -62,7 +62,7 @@ const SignInModal: React.FC = () => {
62
62
 
63
63
  const insets = useSafeAreaInsets();
64
64
  const theme = useTheme();
65
- const { oxyServices, switchSession, clientId } = useOxy();
65
+ const { oxyServices, handleWebSession, clientId } = useOxy();
66
66
 
67
67
  // Register the imperative visibility callback.
68
68
  useEffect(() => {
@@ -83,7 +83,7 @@ const SignInModal: React.FC = () => {
83
83
  theme={theme}
84
84
  insets={insets}
85
85
  oxyServices={oxyServices}
86
- switchSession={switchSession}
86
+ handleWebSession={handleWebSession}
87
87
  clientId={clientId}
88
88
  />
89
89
  );
@@ -93,7 +93,7 @@ interface SignInModalContentProps {
93
93
  theme: ReturnType<typeof useTheme>;
94
94
  insets: ReturnType<typeof useSafeAreaInsets>;
95
95
  oxyServices: ReturnType<typeof useOxy>['oxyServices'];
96
- switchSession: ReturnType<typeof useOxy>['switchSession'];
96
+ handleWebSession: ReturnType<typeof useOxy>['handleWebSession'];
97
97
  clientId: ReturnType<typeof useOxy>['clientId'];
98
98
  }
99
99
 
@@ -101,13 +101,13 @@ const SignInModalContent: React.FC<SignInModalContentProps> = ({
101
101
  theme,
102
102
  insets,
103
103
  oxyServices,
104
- switchSession,
104
+ handleWebSession,
105
105
  clientId,
106
106
  }) => {
107
107
  const { qrData, qrPayload, isLoading, error, isWaiting, openAuthApproval, openSameDeviceApproval, retry } = useOxyAuthSession(
108
108
  oxyServices,
109
109
  clientId,
110
- switchSession,
110
+ handleWebSession,
111
111
  { onSignedIn: hideSignInModal },
112
112
  );
113
113