@oxyhq/services 14.0.1 → 15.0.0

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 (262) hide show
  1. package/lib/commonjs/index.js +0 -17
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/SignInAccountChooser.js +189 -0
  4. package/lib/commonjs/ui/components/SignInAccountChooser.js.map +1 -0
  5. package/lib/commonjs/ui/components/SignInModal.js +350 -131
  6. package/lib/commonjs/ui/components/SignInModal.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +340 -1547
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +61 -64
  10. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useAuth.js +14 -33
  12. package/lib/commonjs/ui/hooks/useAuth.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/usePasswordSignIn.js +170 -0
  14. package/lib/commonjs/ui/hooks/usePasswordSignIn.js.map +1 -0
  15. package/lib/commonjs/ui/hooks/useProfileEditing.js +2 -5
  16. package/lib/commonjs/ui/hooks/useProfileEditing.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useSessionManagement.js +2 -2
  18. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  19. package/lib/commonjs/ui/navigation/routes.js +1 -0
  20. package/lib/commonjs/ui/navigation/routes.js.map +1 -1
  21. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js +93 -68
  22. package/lib/commonjs/ui/screens/EditProfileFieldScreen.js.map +1 -1
  23. package/lib/commonjs/ui/screens/EditProfileScreen.js +187 -0
  24. package/lib/commonjs/ui/screens/EditProfileScreen.js.map +1 -0
  25. package/lib/commonjs/ui/screens/ManageAccountScreen.js +3 -7
  26. package/lib/commonjs/ui/screens/ManageAccountScreen.js.map +1 -1
  27. package/lib/commonjs/ui/screens/OxyAuthScreen.js +231 -95
  28. package/lib/commonjs/ui/screens/OxyAuthScreen.js.map +1 -1
  29. package/lib/commonjs/ui/screens/ProfileScreen.js +15 -2
  30. package/lib/commonjs/ui/screens/ProfileScreen.js.map +1 -1
  31. package/lib/commonjs/ui/screens/linkFormat.js +38 -0
  32. package/lib/commonjs/ui/screens/linkFormat.js.map +1 -0
  33. package/lib/commonjs/ui/session/authStore.js +146 -0
  34. package/lib/commonjs/ui/session/authStore.js.map +1 -0
  35. package/lib/commonjs/ui/session/createSessionClient.js +25 -10
  36. package/lib/commonjs/ui/session/createSessionClient.js.map +1 -1
  37. package/lib/commonjs/ui/session/index.js +13 -7
  38. package/lib/commonjs/ui/session/index.js.map +1 -1
  39. package/lib/commonjs/ui/session/tokenTransport.js +19 -31
  40. package/lib/commonjs/ui/session/tokenTransport.js.map +1 -1
  41. package/lib/commonjs/ui/utils/isWebBrowser.js +13 -0
  42. package/lib/commonjs/ui/utils/isWebBrowser.js.map +1 -0
  43. package/lib/commonjs/utils/deviceFlowSignIn.js +13 -16
  44. package/lib/commonjs/utils/deviceFlowSignIn.js.map +1 -1
  45. package/lib/module/index.js +0 -5
  46. package/lib/module/index.js.map +1 -1
  47. package/lib/module/ui/components/SignInAccountChooser.js +183 -0
  48. package/lib/module/ui/components/SignInAccountChooser.js.map +1 -0
  49. package/lib/module/ui/components/SignInModal.js +352 -134
  50. package/lib/module/ui/components/SignInModal.js.map +1 -1
  51. package/lib/module/ui/context/OxyContext.js +342 -1551
  52. package/lib/module/ui/context/OxyContext.js.map +1 -1
  53. package/lib/module/ui/context/hooks/useAuthOperations.js +60 -63
  54. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  55. package/lib/module/ui/hooks/useAuth.js +14 -33
  56. package/lib/module/ui/hooks/useAuth.js.map +1 -1
  57. package/lib/module/ui/hooks/usePasswordSignIn.js +165 -0
  58. package/lib/module/ui/hooks/usePasswordSignIn.js.map +1 -0
  59. package/lib/module/ui/hooks/useProfileEditing.js +2 -5
  60. package/lib/module/ui/hooks/useProfileEditing.js.map +1 -1
  61. package/lib/module/ui/hooks/useSessionManagement.js +1 -1
  62. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  63. package/lib/module/ui/navigation/routes.js +1 -0
  64. package/lib/module/ui/navigation/routes.js.map +1 -1
  65. package/lib/module/ui/screens/EditProfileFieldScreen.js +91 -66
  66. package/lib/module/ui/screens/EditProfileFieldScreen.js.map +1 -1
  67. package/lib/module/ui/screens/EditProfileScreen.js +182 -0
  68. package/lib/module/ui/screens/EditProfileScreen.js.map +1 -0
  69. package/lib/module/ui/screens/ManageAccountScreen.js +3 -7
  70. package/lib/module/ui/screens/ManageAccountScreen.js.map +1 -1
  71. package/lib/module/ui/screens/OxyAuthScreen.js +233 -97
  72. package/lib/module/ui/screens/OxyAuthScreen.js.map +1 -1
  73. package/lib/module/ui/screens/ProfileScreen.js +15 -2
  74. package/lib/module/ui/screens/ProfileScreen.js.map +1 -1
  75. package/lib/module/ui/screens/linkFormat.js +31 -0
  76. package/lib/module/ui/screens/linkFormat.js.map +1 -0
  77. package/lib/module/ui/session/authStore.js +143 -0
  78. package/lib/module/ui/session/authStore.js.map +1 -0
  79. package/lib/module/ui/session/createSessionClient.js +25 -10
  80. package/lib/module/ui/session/createSessionClient.js.map +1 -1
  81. package/lib/module/ui/session/index.js +9 -9
  82. package/lib/module/ui/session/index.js.map +1 -1
  83. package/lib/module/ui/session/tokenTransport.js +20 -33
  84. package/lib/module/ui/session/tokenTransport.js.map +1 -1
  85. package/lib/module/ui/utils/isWebBrowser.js +11 -0
  86. package/lib/module/ui/utils/isWebBrowser.js.map +1 -0
  87. package/lib/module/utils/deviceFlowSignIn.js +14 -17
  88. package/lib/module/utils/deviceFlowSignIn.js.map +1 -1
  89. package/lib/typescript/commonjs/index.d.ts +0 -1
  90. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts +29 -0
  92. package/lib/typescript/commonjs/ui/components/SignInAccountChooser.d.ts.map +1 -0
  93. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts +15 -10
  94. package/lib/typescript/commonjs/ui/components/SignInModal.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +36 -42
  96. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts +18 -23
  98. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
  100. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/ui/hooks/useAuth.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts +31 -0
  103. package/lib/typescript/commonjs/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts +0 -1
  105. package/lib/typescript/commonjs/ui/hooks/useProfileEditing.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/navigation/routes.d.ts +1 -1
  107. package/lib/typescript/commonjs/ui/navigation/routes.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts +14 -0
  110. package/lib/typescript/commonjs/ui/screens/EditProfileScreen.d.ts.map +1 -0
  111. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts +1 -1
  112. package/lib/typescript/commonjs/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts +13 -12
  114. package/lib/typescript/commonjs/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/ui/screens/ProfileScreen.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts +23 -0
  117. package/lib/typescript/commonjs/ui/screens/linkFormat.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/ui/session/authStore.d.ts +33 -0
  119. package/lib/typescript/commonjs/ui/session/authStore.d.ts.map +1 -0
  120. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts +20 -9
  121. package/lib/typescript/commonjs/ui/session/createSessionClient.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/ui/session/index.d.ts +9 -9
  123. package/lib/typescript/commonjs/ui/session/index.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts +13 -19
  125. package/lib/typescript/commonjs/ui/session/tokenTransport.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts +9 -0
  127. package/lib/typescript/commonjs/ui/utils/isWebBrowser.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts +15 -13
  129. package/lib/typescript/commonjs/utils/deviceFlowSignIn.d.ts.map +1 -1
  130. package/lib/typescript/module/index.d.ts +0 -1
  131. package/lib/typescript/module/index.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts +29 -0
  133. package/lib/typescript/module/ui/components/SignInAccountChooser.d.ts.map +1 -0
  134. package/lib/typescript/module/ui/components/SignInModal.d.ts +15 -10
  135. package/lib/typescript/module/ui/components/SignInModal.d.ts.map +1 -1
  136. package/lib/typescript/module/ui/context/OxyContext.d.ts +36 -42
  137. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts +18 -23
  139. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  140. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +0 -2
  141. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/hooks/useAuth.d.ts.map +1 -1
  143. package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts +31 -0
  144. package/lib/typescript/module/ui/hooks/usePasswordSignIn.d.ts.map +1 -0
  145. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts +0 -1
  146. package/lib/typescript/module/ui/hooks/useProfileEditing.d.ts.map +1 -1
  147. package/lib/typescript/module/ui/navigation/routes.d.ts +1 -1
  148. package/lib/typescript/module/ui/navigation/routes.d.ts.map +1 -1
  149. package/lib/typescript/module/ui/screens/EditProfileFieldScreen.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts +14 -0
  151. package/lib/typescript/module/ui/screens/EditProfileScreen.d.ts.map +1 -0
  152. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts +1 -1
  153. package/lib/typescript/module/ui/screens/ManageAccountScreen.d.ts.map +1 -1
  154. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts +13 -12
  155. package/lib/typescript/module/ui/screens/OxyAuthScreen.d.ts.map +1 -1
  156. package/lib/typescript/module/ui/screens/ProfileScreen.d.ts.map +1 -1
  157. package/lib/typescript/module/ui/screens/linkFormat.d.ts +23 -0
  158. package/lib/typescript/module/ui/screens/linkFormat.d.ts.map +1 -0
  159. package/lib/typescript/module/ui/session/authStore.d.ts +33 -0
  160. package/lib/typescript/module/ui/session/authStore.d.ts.map +1 -0
  161. package/lib/typescript/module/ui/session/createSessionClient.d.ts +20 -9
  162. package/lib/typescript/module/ui/session/createSessionClient.d.ts.map +1 -1
  163. package/lib/typescript/module/ui/session/index.d.ts +9 -9
  164. package/lib/typescript/module/ui/session/index.d.ts.map +1 -1
  165. package/lib/typescript/module/ui/session/tokenTransport.d.ts +13 -19
  166. package/lib/typescript/module/ui/session/tokenTransport.d.ts.map +1 -1
  167. package/lib/typescript/module/ui/utils/isWebBrowser.d.ts +9 -0
  168. package/lib/typescript/module/ui/utils/isWebBrowser.d.ts.map +1 -0
  169. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts +15 -13
  170. package/lib/typescript/module/utils/deviceFlowSignIn.d.ts.map +1 -1
  171. package/package.json +4 -4
  172. package/src/index.ts +0 -4
  173. package/src/ui/components/SignInAccountChooser.tsx +162 -0
  174. package/src/ui/components/SignInModal.tsx +309 -139
  175. package/src/ui/context/OxyContext.tsx +532 -1785
  176. package/src/ui/context/hooks/useAuthOperations.ts +65 -76
  177. package/src/ui/hooks/useAuth.ts +14 -35
  178. package/src/ui/hooks/usePasswordSignIn.ts +207 -0
  179. package/src/ui/hooks/useProfileEditing.ts +2 -8
  180. package/src/ui/hooks/useSessionManagement.ts +1 -1
  181. package/src/ui/navigation/routes.ts +2 -0
  182. package/src/ui/screens/EditProfileFieldScreen.tsx +102 -66
  183. package/src/ui/screens/EditProfileScreen.tsx +155 -0
  184. package/src/ui/screens/ManageAccountScreen.tsx +3 -7
  185. package/src/ui/screens/OxyAuthScreen.tsx +259 -112
  186. package/src/ui/screens/ProfileScreen.tsx +15 -2
  187. package/src/ui/screens/__tests__/linkFormat.test.ts +73 -0
  188. package/src/ui/screens/linkFormat.ts +37 -0
  189. package/src/ui/session/__tests__/createSessionClient.test.ts +49 -6
  190. package/src/ui/session/__tests__/tokenTransport.test.ts +37 -49
  191. package/src/ui/session/authStore.ts +164 -0
  192. package/src/ui/session/createSessionClient.ts +32 -11
  193. package/src/ui/session/index.ts +10 -9
  194. package/src/ui/session/tokenTransport.ts +26 -36
  195. package/src/ui/utils/isWebBrowser.ts +8 -0
  196. package/src/utils/__tests__/deviceFlowSignIn.test.ts +50 -131
  197. package/src/utils/deviceFlowSignIn.ts +19 -26
  198. package/lib/commonjs/ui/context/inSessionTokenRefresh.js +0 -243
  199. package/lib/commonjs/ui/context/inSessionTokenRefresh.js.map +0 -1
  200. package/lib/commonjs/ui/context/silentSessionRestore.js +0 -56
  201. package/lib/commonjs/ui/context/silentSessionRestore.js.map +0 -1
  202. package/lib/commonjs/ui/hooks/useWebSSO.js +0 -28
  203. package/lib/commonjs/ui/hooks/useWebSSO.js.map +0 -1
  204. package/lib/commonjs/ui/session/projectSessionState.js +0 -86
  205. package/lib/commonjs/ui/session/projectSessionState.js.map +0 -1
  206. package/lib/commonjs/ui/session/sessionClientHost.js +0 -30
  207. package/lib/commonjs/ui/session/sessionClientHost.js.map +0 -1
  208. package/lib/commonjs/ui/utils/activeAuthuser.js +0 -142
  209. package/lib/commonjs/ui/utils/activeAuthuser.js.map +0 -1
  210. package/lib/commonjs/utils/crossApex.js +0 -74
  211. package/lib/commonjs/utils/crossApex.js.map +0 -1
  212. package/lib/module/ui/context/inSessionTokenRefresh.js +0 -238
  213. package/lib/module/ui/context/inSessionTokenRefresh.js.map +0 -1
  214. package/lib/module/ui/context/silentSessionRestore.js +0 -53
  215. package/lib/module/ui/context/silentSessionRestore.js.map +0 -1
  216. package/lib/module/ui/hooks/useWebSSO.js +0 -24
  217. package/lib/module/ui/hooks/useWebSSO.js.map +0 -1
  218. package/lib/module/ui/session/projectSessionState.js +0 -79
  219. package/lib/module/ui/session/projectSessionState.js.map +0 -1
  220. package/lib/module/ui/session/sessionClientHost.js +0 -26
  221. package/lib/module/ui/session/sessionClientHost.js.map +0 -1
  222. package/lib/module/ui/utils/activeAuthuser.js +0 -134
  223. package/lib/module/ui/utils/activeAuthuser.js.map +0 -1
  224. package/lib/module/utils/crossApex.js +0 -69
  225. package/lib/module/utils/crossApex.js.map +0 -1
  226. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts +0 -106
  227. package/lib/typescript/commonjs/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
  228. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts +0 -36
  229. package/lib/typescript/commonjs/ui/context/silentSessionRestore.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts +0 -19
  231. package/lib/typescript/commonjs/ui/hooks/useWebSSO.d.ts.map +0 -1
  232. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts +0 -45
  233. package/lib/typescript/commonjs/ui/session/projectSessionState.d.ts.map +0 -1
  234. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts +0 -14
  235. package/lib/typescript/commonjs/ui/session/sessionClientHost.d.ts.map +0 -1
  236. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +0 -67
  237. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +0 -1
  238. package/lib/typescript/commonjs/utils/crossApex.d.ts +0 -55
  239. package/lib/typescript/commonjs/utils/crossApex.d.ts.map +0 -1
  240. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts +0 -106
  241. package/lib/typescript/module/ui/context/inSessionTokenRefresh.d.ts.map +0 -1
  242. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts +0 -36
  243. package/lib/typescript/module/ui/context/silentSessionRestore.d.ts.map +0 -1
  244. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts +0 -19
  245. package/lib/typescript/module/ui/hooks/useWebSSO.d.ts.map +0 -1
  246. package/lib/typescript/module/ui/session/projectSessionState.d.ts +0 -45
  247. package/lib/typescript/module/ui/session/projectSessionState.d.ts.map +0 -1
  248. package/lib/typescript/module/ui/session/sessionClientHost.d.ts +0 -14
  249. package/lib/typescript/module/ui/session/sessionClientHost.d.ts.map +0 -1
  250. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +0 -67
  251. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +0 -1
  252. package/lib/typescript/module/utils/crossApex.d.ts +0 -55
  253. package/lib/typescript/module/utils/crossApex.d.ts.map +0 -1
  254. package/src/ui/context/inSessionTokenRefresh.ts +0 -264
  255. package/src/ui/context/silentSessionRestore.ts +0 -53
  256. package/src/ui/hooks/useWebSSO.ts +0 -23
  257. package/src/ui/session/__tests__/projectSessionState.test.ts +0 -114
  258. package/src/ui/session/__tests__/sessionClientHost.test.ts +0 -43
  259. package/src/ui/session/projectSessionState.ts +0 -85
  260. package/src/ui/session/sessionClientHost.ts +0 -27
  261. package/src/ui/utils/activeAuthuser.ts +0 -142
  262. package/src/utils/crossApex.ts +0 -75
@@ -1,22 +1,24 @@
1
1
  /**
2
- * OxyAuthScreen - Sign in with Oxy (native bottom-sheet container)
2
+ * OxyAuthScreen Sign in with Oxy (native bottom-sheet container).
3
3
  *
4
- * Used by OTHER apps in the Oxy ecosystem to authenticate users. The primary
5
- * action is the one-tap "Continue with Oxy" approval flow (opens the Oxy Auth
6
- * web flow with a deep-link `redirect_uri` so the app→app return path can
7
- * complete the sign-in). The QR code is demoted to a collapsed "Sign in on
8
- * another device" disclosure you can't scan your own screen.
4
+ * Two phases, Google-style:
5
+ * 1. Account chooser (FRONT screen, shown when the device/user already has
6
+ * accounts): pick an account to continue as one tap switches through the
7
+ * SAME `switchToAccount` path the account switcher uses or "Use another
8
+ * account" to reveal the sign-in options.
9
+ * 2. Sign-in options: the first-party password flow (identifier → password →
10
+ * optional 2FA, `usePasswordSignIn`) as the PRIMARY action, with the
11
+ * cross-app device flow (same-device deep-link + "sign in on another device"
12
+ * QR) as a SECONDARY option below an "or" divider.
9
13
  *
10
- * ALL of the auth-session machinery (session-token creation, QR data, socket +
11
- * polling, the native deep-link return path, waiting/error/retry state, the
12
- * open-auth handler, and cleanup) lives in the shared `useOxyAuthSession` hook,
13
- * which the web `SignInModal` also consumes — neither container re-implements
14
- * the transport. The Oxy Accounts app is where users manage their cryptographic
15
- * identity; this screen should NOT be used within the Accounts app itself.
14
+ * The device-flow machinery lives in the shared `useOxyAuthSession` hook (the
15
+ * web `SignInModal` consumes it too). This screen should NOT be used within the
16
+ * Oxy Accounts app itself.
16
17
  */
17
18
 
18
19
  import type React from 'react';
19
- import { View, Linking } from 'react-native';
20
+ import { useCallback, useState } from 'react';
21
+ import { View, TextInput, Linking, type TextStyle } from 'react-native';
20
22
  import type { BaseScreenProps } from '../types/navigation';
21
23
  import { useTheme } from '@oxyhq/bloom/theme';
22
24
  import { Button } from '@oxyhq/bloom/button';
@@ -24,129 +26,274 @@ import { Loading } from '@oxyhq/bloom/loading';
24
26
  import { H4, Text } from '@oxyhq/bloom/typography';
25
27
  import { IconCircle } from '@oxyhq/bloom/icon-circle';
26
28
  import * as Icons from '@oxyhq/bloom/icons';
29
+ import { toast } from '@oxyhq/bloom';
30
+ import { isDev, logger as loggerUtil } from '@oxyhq/core';
27
31
  import { useOxy } from '../context/OxyContext';
28
32
  import OxyLogo from '../components/OxyLogo';
29
33
  import AnotherDeviceQR from '../components/AnotherDeviceQR';
30
34
  import LoadingState from '../components/LoadingState';
35
+ import SignInAccountChooser from '../components/SignInAccountChooser';
36
+ import { useSwitchableAccounts, type SwitchableAccount } from '../hooks/useSwitchableAccounts';
37
+ import { useI18n } from '../hooks/useI18n';
31
38
  import { useOxyAuthSession, OXY_ACCOUNTS_WEB_URL } from '../hooks/useOxyAuthSession';
32
- import { isCrossApexWeb } from '../../utils/crossApex';
39
+ import { usePasswordSignIn } from '../hooks/usePasswordSignIn';
33
40
 
34
41
  const OxyAuthScreen: React.FC<BaseScreenProps> = ({ goBack, onAuthenticated }) => {
35
42
  const bloomTheme = useTheme();
36
- const { oxyServices, handleWebSession, clientId } = useOxy();
43
+ const { oxyServices, handleWebSession, clientId, switchToAccount } = useOxy();
44
+ const { t } = useI18n();
45
+ const { accounts } = useSwitchableAccounts();
37
46
 
38
- const { qrData, qrPayload, isLoading, error, isWaiting, openAuthApproval, openSameDeviceApproval, retry } = useOxyAuthSession(
47
+ const [useAnother, setUseAnother] = useState(false);
48
+ const [switchingId, setSwitchingId] = useState<string | null>(null);
49
+ const showChooser = !useAnother && accounts.length > 0;
50
+
51
+ const { qrData, qrPayload, isLoading, error, isWaiting, openSameDeviceApproval, retry } = useOxyAuthSession(
39
52
  oxyServices,
40
53
  clientId,
41
54
  handleWebSession,
42
55
  { onSignedIn: onAuthenticated },
43
56
  );
44
57
 
45
- // On a cross-apex web RP, only the "Continue with Oxy" IdP popup establishes a
46
- // durable `fedcm_session`. The Commons-app handoffs (same-device deep-link +
47
- // cross-device QR) approve OUTSIDE the browser, so they leave no IdP session
48
- // and the user would be logged out on reload — hide them there. Off-browser
49
- // (native) this is always `false`, so the bottom sheet is unchanged.
50
- const crossApexWeb = isCrossApexWeb();
51
-
52
- if (isLoading) {
53
- return (
54
- <View className="flex-1 items-center justify-center bg-bg">
55
- <LoadingState
56
- size="large"
57
- color={bloomTheme.colors.primary}
58
- message="Preparing sign in..."
59
- />
60
- </View>
61
- );
62
- }
63
-
64
- if (error) {
65
- return (
66
- <View className="flex-1 items-center justify-center bg-bg px-screen-margin gap-space-16">
67
- <IconCircle icon={Icons.Warning_Stroke2_Corner0_Rounded} />
68
- <Text className="font-sans text-body text-text-secondary text-center">{error}</Text>
69
- <Button
70
- variant="primary"
71
- fullWidth
72
- className="w-full"
73
- onPress={retry}
74
- icon={
75
- <Icons.ArrowRotateClockwise_Stroke2_Corner0_Rounded
76
- size="sm"
77
- style={{ color: bloomTheme.colors.primaryForeground }}
78
- />
79
- }
80
- >
81
- Try Again
82
- </Button>
83
- </View>
84
- );
85
- }
58
+ const pw = usePasswordSignIn({ onSignedIn: onAuthenticated });
59
+
60
+ const handleSelectAccount = useCallback(async (account: SwitchableAccount) => {
61
+ if (account.isCurrent) {
62
+ onAuthenticated?.();
63
+ return;
64
+ }
65
+ if (switchingId) return;
66
+ setSwitchingId(account.accountId);
67
+ try {
68
+ await switchToAccount(account.accountId);
69
+ onAuthenticated?.();
70
+ } catch (switchError) {
71
+ if (isDev()) {
72
+ loggerUtil.warn('OxyAuthScreen: switch account failed', { component: 'OxyAuthScreen' }, switchError as unknown);
73
+ }
74
+ toast.error(t('accountSwitcher.toasts.switchFailed') || 'Failed to switch account');
75
+ } finally {
76
+ setSwitchingId(null);
77
+ }
78
+ }, [switchingId, switchToAccount, onAuthenticated, t]);
79
+
80
+ const inputStyle: TextStyle = {
81
+ width: '100%',
82
+ borderWidth: 1,
83
+ borderRadius: 12,
84
+ paddingHorizontal: 16,
85
+ paddingVertical: 13,
86
+ fontSize: 15,
87
+ marginBottom: 12,
88
+ borderColor: bloomTheme.colors.border,
89
+ color: bloomTheme.colors.text,
90
+ backgroundColor: bloomTheme.colors.backgroundSecondary,
91
+ };
92
+
93
+ const title = showChooser ? t('signin.chooser.title') : 'Sign in to Oxy';
94
+ const subtitle = showChooser
95
+ ? t('signin.chooser.subtitle')
96
+ : 'Continue with your Oxy identity to sign in securely';
86
97
 
87
98
  return (
88
99
  <View className="flex-1 items-center justify-center bg-bg px-screen-margin">
89
100
  {/* Branded header */}
90
101
  <View className="items-center mb-space-24 gap-space-12">
91
- <OxyLogo variant="icon" size={56} />
92
- <H4 className="text-headerBold font-headerBold text-text text-center">
93
- Sign in to Oxy
94
- </H4>
95
- <Text className="font-sans text-body text-text-secondary text-center">
96
- Continue with your Oxy identity to sign in securely
97
- </Text>
102
+ <OxyLogo variant="icon" size={52} />
103
+ <H4 className="text-headerBold font-headerBold text-text text-center">{title}</H4>
104
+ <Text className="font-sans text-body text-text-secondary text-center">{subtitle}</Text>
98
105
  </View>
99
106
 
100
- {/* Primary action — Continue with Oxy */}
101
- <Button
102
- variant="primary"
103
- size="large"
104
- fullWidth
105
- className="w-full"
106
- onPress={openAuthApproval}
107
- icon={
108
- <OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.primaryForeground} />
109
- }
110
- >
111
- Continue with Oxy
112
- </Button>
113
-
114
- {/* Same-device "Sign in with Oxy" handoff — deep-links into the native Oxy
115
- app to approve. Shown only when the handoff backend returned a payload,
116
- and never on a cross-apex web RP (approval happens outside the browser
117
- no durable session). */}
118
- {!crossApexWeb && qrPayload && (
119
- <Button
120
- variant="secondary"
121
- size="large"
122
- fullWidth
123
- className="w-full mt-space-12"
124
- onPress={openSameDeviceApproval}
125
- icon={
126
- <OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.text} />
127
- }
128
- >
129
- Sign in with the Oxy app
130
- </Button>
131
- )}
107
+ {showChooser ? (
108
+ <SignInAccountChooser
109
+ accounts={accounts}
110
+ onSelectAccount={handleSelectAccount}
111
+ onUseAnother={() => setUseAnother(true)}
112
+ pendingAccountId={switchingId}
113
+ disabled={switchingId !== null}
114
+ />
115
+ ) : (
116
+ <>
117
+ {/* Back to the chooser when accounts exist. */}
118
+ {accounts.length > 0 && (
119
+ <Button
120
+ variant="text"
121
+ size="small"
122
+ className="self-start mb-space-8"
123
+ onPress={() => setUseAnother(false)}
124
+ accessibilityLabel="Choose an account"
125
+ icon={<Icons.ArrowLeft_Stroke2_Corner0_Rounded size="sm" style={{ color: bloomTheme.colors.textSecondary }} />}
126
+ >
127
+ Choose an account
128
+ </Button>
129
+ )}
132
130
 
133
- {/* Waiting status */}
134
- {isWaiting && (
135
- <View className="flex-row items-center mt-space-16 gap-space-8">
136
- <Loading size="small" />
137
- <Text className="font-sans text-body text-text-secondary">
138
- Waiting for authorization...
139
- </Text>
140
- </View>
141
- )}
131
+ {/* PRIMARY first-party password sign-in. Always usable; the device-flow
132
+ loading/error state below never gates it. */}
133
+ <View className="w-full">
134
+ {pw.step === 'identifier' && (
135
+ <TextInput
136
+ style={inputStyle}
137
+ value={pw.identifier}
138
+ onChangeText={pw.setIdentifier}
139
+ onSubmitEditing={pw.submitIdentifier}
140
+ placeholder="Username or email"
141
+ placeholderTextColor={bloomTheme.colors.textSecondary}
142
+ autoCapitalize="none"
143
+ autoCorrect={false}
144
+ keyboardType="email-address"
145
+ returnKeyType="next"
146
+ accessibilityLabel="Username or email"
147
+ />
148
+ )}
149
+
150
+ {pw.step === 'password' && (
151
+ <>
152
+ <Text className="font-sans text-body text-text-secondary text-center mb-space-12">
153
+ {pw.identifier}
154
+ </Text>
155
+ <TextInput
156
+ style={inputStyle}
157
+ value={pw.password}
158
+ onChangeText={pw.setPassword}
159
+ onSubmitEditing={pw.submitPassword}
160
+ placeholder="Password"
161
+ placeholderTextColor={bloomTheme.colors.textSecondary}
162
+ secureTextEntry
163
+ autoCapitalize="none"
164
+ autoCorrect={false}
165
+ returnKeyType="go"
166
+ accessibilityLabel="Password"
167
+ />
168
+ </>
169
+ )}
170
+
171
+ {pw.step === 'twoFactor' && (
172
+ <TextInput
173
+ style={inputStyle}
174
+ value={pw.code}
175
+ onChangeText={pw.setCode}
176
+ onSubmitEditing={pw.submitTwoFactor}
177
+ placeholder={pw.useBackupCode ? 'Backup code' : '6-digit code'}
178
+ placeholderTextColor={bloomTheme.colors.textSecondary}
179
+ autoCapitalize="none"
180
+ autoCorrect={false}
181
+ keyboardType={pw.useBackupCode ? 'default' : 'number-pad'}
182
+ returnKeyType="go"
183
+ accessibilityLabel={pw.useBackupCode ? 'Backup code' : 'Two-factor code'}
184
+ />
185
+ )}
186
+
187
+ <Button
188
+ variant="primary"
189
+ size="large"
190
+ fullWidth
191
+ className="w-full"
192
+ onPress={
193
+ pw.step === 'identifier' ? pw.submitIdentifier
194
+ : pw.step === 'password' ? pw.submitPassword
195
+ : pw.submitTwoFactor
196
+ }
197
+ loading={pw.isSubmitting}
198
+ disabled={pw.isSubmitting}
199
+ >
200
+ {pw.step === 'identifier' ? 'Continue' : pw.step === 'password' ? 'Sign in' : 'Verify'}
201
+ </Button>
202
+
203
+ {pw.error && (
204
+ <Text className="font-sans text-body text-destructive text-center mt-space-12">
205
+ {pw.error}
206
+ </Text>
207
+ )}
208
+
209
+ {pw.step === 'twoFactor' && (
210
+ <Button
211
+ variant="text"
212
+ size="small"
213
+ className="mt-space-8"
214
+ onPress={() => pw.setUseBackupCode(!pw.useBackupCode)}
215
+ >
216
+ {pw.useBackupCode ? 'Use authenticator code' : 'Use a backup code'}
217
+ </Button>
218
+ )}
219
+
220
+ {pw.step !== 'identifier' && (
221
+ <Button
222
+ variant="text"
223
+ size="small"
224
+ className="mt-space-8"
225
+ onPress={pw.back}
226
+ accessibilityLabel="Back"
227
+ >
228
+ Back
229
+ </Button>
230
+ )}
231
+ </View>
232
+
233
+ {/* "or" divider — separates the password form from the SECONDARY device flow. */}
234
+ <View className="flex-row items-center w-full my-space-16 gap-space-8">
235
+ <View className="flex-1" style={{ height: 1, backgroundColor: bloomTheme.colors.border }} />
236
+ <Text className="font-sans text-caption text-text-tertiary">or</Text>
237
+ <View className="flex-1" style={{ height: 1, backgroundColor: bloomTheme.colors.border }} />
238
+ </View>
239
+
240
+ {/* SECONDARY — cross-app device flow. Its loading/error state gates ONLY
241
+ this section; the password form above is always usable. */}
242
+ {isLoading ? (
243
+ <LoadingState
244
+ size="small"
245
+ color={bloomTheme.colors.primary}
246
+ message="Preparing sign in…"
247
+ />
248
+ ) : error ? (
249
+ <View className="items-center gap-space-16 w-full">
250
+ <IconCircle icon={Icons.Warning_Stroke2_Corner0_Rounded} />
251
+ <Text className="font-sans text-body text-text-secondary text-center">{error}</Text>
252
+ <Button
253
+ variant="secondary"
254
+ fullWidth
255
+ className="w-full"
256
+ onPress={retry}
257
+ icon={
258
+ <Icons.ArrowRotateClockwise_Stroke2_Corner0_Rounded
259
+ size="sm"
260
+ style={{ color: bloomTheme.colors.text }}
261
+ />
262
+ }
263
+ >
264
+ Try Again
265
+ </Button>
266
+ </View>
267
+ ) : (
268
+ <>
269
+ {qrPayload && (
270
+ <Button
271
+ variant="secondary"
272
+ size="large"
273
+ fullWidth
274
+ className="w-full"
275
+ onPress={openSameDeviceApproval}
276
+ icon={<OxyLogo variant="icon" size={20} fillColor={bloomTheme.colors.text} />}
277
+ >
278
+ Sign in with the Oxy app
279
+ </Button>
280
+ )}
281
+
282
+ {isWaiting && (
283
+ <View className="flex-row items-center mt-space-16 gap-space-8">
284
+ <Loading size="small" />
285
+ <Text className="font-sans text-body text-text-secondary">
286
+ Waiting for authorization…
287
+ </Text>
288
+ </View>
289
+ )}
142
290
 
143
- {/* Collapsed "sign in on another device" QR disclosure. Hidden on a
144
- cross-apex web RP: a remote Commons-app approval completes via the
145
- device-flow claim, which plants no `fedcm_session`. */}
146
- {!crossApexWeb && (
147
- <View className="w-full mt-space-24">
148
- <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
149
- </View>
291
+ <View className="w-full mt-space-24">
292
+ <AnotherDeviceQR qrData={qrData} qrPayload={qrPayload} />
293
+ </View>
294
+ </>
295
+ )}
296
+ </>
150
297
  )}
151
298
 
152
299
  {/* Footer — create an account */}
@@ -178,6 +178,19 @@ const ProfileScreen: React.FC<ProfileScreenProps> = ({ userId, username, theme,
178
178
 
179
179
  const displayName = profile ? getAccountDisplayName(profile, locale) : username || '';
180
180
 
181
+ // The singular `location` field was removed from the User contract; derive
182
+ // the primary place from the `locations` list instead. `locations` is only
183
+ // reachable through the User index signature (typed `unknown`), so narrow it
184
+ // defensively before rendering the chip.
185
+ const primaryLocation = ((): string | undefined => {
186
+ const locations = profile?.locations;
187
+ const first: unknown = Array.isArray(locations) ? locations[0] : undefined;
188
+ if (first && typeof first === 'object' && 'name' in first && typeof first.name === 'string') {
189
+ return first.name || undefined;
190
+ }
191
+ return undefined;
192
+ })();
193
+
181
194
  return (
182
195
  <View style={styles.container} className="bg-bg">
183
196
  <ScrollView style={styles.flex} contentContainerStyle={styles.scrollContainer}>
@@ -238,10 +251,10 @@ const ProfileScreen: React.FC<ProfileScreenProps> = ({ userId, username, theme,
238
251
  showChevron={false}
239
252
  />
240
253
  )}
241
- {profile?.location && (
254
+ {primaryLocation && (
242
255
  <SettingsListItem
243
256
  icon={<Ionicons name="location-outline" size={INFO_ICON_SIZE} color={bloomTheme.colors.textSecondary} />}
244
- title={profile.location}
257
+ title={primaryLocation}
245
258
  showChevron={false}
246
259
  />
247
260
  )}
@@ -0,0 +1,73 @@
1
+ import {
2
+ getLinkTitle,
3
+ getLinkDescription,
4
+ linksToListItems,
5
+ } from '../linkFormat';
6
+
7
+ describe('linkFormat', () => {
8
+ describe('getLinkTitle', () => {
9
+ it.each([
10
+ ['https://example.com', 'example.com'],
11
+ ['http://example.com/', 'example.com'],
12
+ ['https://example.com/path/', 'example.com/path'],
13
+ ['example.com', 'example.com'],
14
+ ])('strips protocol and trailing slash: %p → %p', (input, expected) => {
15
+ expect(getLinkTitle(input)).toBe(expected);
16
+ });
17
+ });
18
+
19
+ describe('getLinkDescription', () => {
20
+ it('prefixes the url with "Link to "', () => {
21
+ expect(getLinkDescription('https://example.com')).toBe('Link to https://example.com');
22
+ });
23
+ });
24
+
25
+ describe('linksToListItems', () => {
26
+ it('maps a normal string array, preserving valid-link rendering', () => {
27
+ const result = linksToListItems(['https://example.com/', 'http://oxy.so']);
28
+ expect(result).toEqual([
29
+ {
30
+ id: 'link-0',
31
+ url: 'https://example.com/',
32
+ title: 'example.com',
33
+ description: 'Link to https://example.com/',
34
+ },
35
+ {
36
+ id: 'link-1',
37
+ url: 'http://oxy.so',
38
+ title: 'oxy.so',
39
+ description: 'Link to http://oxy.so',
40
+ },
41
+ ]);
42
+ });
43
+
44
+ it.each([
45
+ [null, 'null'],
46
+ [undefined, 'undefined'],
47
+ [42, 'a number'],
48
+ [{ href: 'x' }, 'an object'],
49
+ [['nested'], 'an array'],
50
+ ])('does not throw on a non-string element (%s)', (bad) => {
51
+ // Regression: `links.map(getLinkTitle)` called `.replace` on a
52
+ // non-string element and crashed the editor. The coercion guard must
53
+ // yield a string url/title/description instead.
54
+ const run = () => linksToListItems([bad]);
55
+ expect(run).not.toThrow();
56
+ const [item] = run();
57
+ expect(typeof item.url).toBe('string');
58
+ expect(typeof item.title).toBe('string');
59
+ expect(typeof item.description).toBe('string');
60
+ });
61
+
62
+ it('coerces null / undefined to an empty-string url', () => {
63
+ const result = linksToListItems([null, undefined]);
64
+ expect(result[0].url).toBe('');
65
+ expect(result[0].title).toBe('');
66
+ expect(result[1].url).toBe('');
67
+ });
68
+
69
+ it('returns an empty array for an empty input', () => {
70
+ expect(linksToListItems([])).toEqual([]);
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Pure link-display helpers for the profile "links" editor. Kept out of the
3
+ * screen component so the string-coercion guard can be unit-tested without
4
+ * rendering the whole React Native screen.
5
+ */
6
+
7
+ /** Strip the protocol and any trailing slash from a link URL for display. */
8
+ export const getLinkTitle = (url: string): string =>
9
+ url.replace(/^https?:\/\//, '').replace(/\/$/, '');
10
+
11
+ /** Human-readable description for a link URL. */
12
+ export const getLinkDescription = (url: string): string => `Link to ${url}`;
13
+
14
+ export interface LinkListItem {
15
+ id: string;
16
+ url: string;
17
+ title: string;
18
+ description: string;
19
+ }
20
+
21
+ /**
22
+ * Build editable list items from a raw links array. The array can come from
23
+ * legacy or untrusted profile data whose elements are not guaranteed to be
24
+ * strings, so each entry is coerced with `String(item ?? '')` before formatting
25
+ * — a non-string element must never crash the editor.
26
+ */
27
+ export function linksToListItems(links: readonly unknown[]): LinkListItem[] {
28
+ return links.map((item, i) => {
29
+ const url = String(item ?? '');
30
+ return {
31
+ id: `link-${i}`,
32
+ url,
33
+ title: getLinkTitle(url),
34
+ description: getLinkDescription(url),
35
+ };
36
+ });
37
+ }
@@ -1,4 +1,19 @@
1
- import { SessionClient } from '@oxyhq/core';
1
+ import { SessionClient, createMemoryAuthStateStore } from '@oxyhq/core';
2
+
3
+ type Handler = (...args: unknown[]) => void;
4
+ class FakeSocket {
5
+ connected = false;
6
+ on(_event: string, _cb: Handler) {}
7
+ off(_event: string, _cb?: Handler) {}
8
+ connect() { this.connected = true; }
9
+ disconnect() { this.connected = false; }
10
+ }
11
+ const ioMock = jest.fn((_uri: string, _opts?: Record<string, unknown>) => new FakeSocket());
12
+ // `createSessionClient` STATICALLY imports `io` from socket.io-client and injects it as
13
+ // the SessionClient `socketFactory`; this mock stands in for that real dependency so the
14
+ // wiring test can assert the factory reaches — and is invoked by — the client.
15
+ jest.mock('socket.io-client', () => ({ __esModule: true, io: (...args: unknown[]) => ioMock(...(args as [string, Record<string, unknown>?])) }));
16
+
2
17
  import { createSessionClient } from '../createSessionClient';
3
18
 
4
19
  function fakeOxy() {
@@ -12,16 +27,14 @@ function fakeOxy() {
12
27
  listeners.add(l);
13
28
  return () => listeners.delete(l);
14
29
  }),
15
- silentSignIn: jest.fn().mockResolvedValue(null),
16
- signInWithSharedIdentity: jest.fn().mockResolvedValue(null),
17
30
  };
18
31
  }
19
32
 
20
33
  describe('createSessionClient', () => {
21
- test('wires a SessionClient instance backed by the host + token transport', () => {
34
+ test('wires a SessionClient instance backed by the host + device-first token transport', () => {
22
35
  const oxy = fakeOxy();
23
36
 
24
- const { client, host } = createSessionClient(oxy as never);
37
+ const { client, host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
25
38
 
26
39
  expect(client).toBeInstanceOf(SessionClient);
27
40
  expect(typeof client.bootstrap).toBe('function');
@@ -32,10 +45,40 @@ describe('createSessionClient', () => {
32
45
  test('the returned host reflects setCurrentAccountId', () => {
33
46
  const oxy = fakeOxy();
34
47
 
35
- const { host } = createSessionClient(oxy as never);
48
+ const { host } = createSessionClient(oxy as never, createMemoryAuthStateStore());
36
49
 
37
50
  expect(host.getCurrentAccountId()).toBeNull();
38
51
  host.setCurrentAccountId('u1');
39
52
  expect(host.getCurrentAccountId()).toBe('u1');
40
53
  });
54
+
55
+ test('injects the statically-imported socket.io factory — start() opens a socket without the lazy loader', async () => {
56
+ ioMock.mockClear();
57
+ const oxy = fakeOxy();
58
+
59
+ const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore());
60
+ await client.start();
61
+
62
+ // The injected `io` was used to open the session socket at the base URL.
63
+ expect(ioMock).toHaveBeenCalledTimes(1);
64
+ expect(ioMock).toHaveBeenCalledWith('https://api.oxy.so', expect.objectContaining({ transports: ['websocket'] }));
65
+ client.stop();
66
+ });
67
+
68
+ test('passes onUnauthenticated through to the SessionClient', async () => {
69
+ ioMock.mockClear();
70
+ const oxy = fakeOxy();
71
+ const onUnauthenticated = jest.fn();
72
+
73
+ const { client } = createSessionClient(oxy as never, createMemoryAuthStateStore(), onUnauthenticated);
74
+
75
+ // The client was constructed with the callback (no direct getter is exposed;
76
+ // constructing without throwing + wiring the socket factory is the contract
77
+ // this factory owns — the callback firing on a zero-account applied state is
78
+ // covered by @oxyhq/core's SessionClient tests).
79
+ expect(client).toBeInstanceOf(SessionClient);
80
+ await client.start();
81
+ expect(ioMock).toHaveBeenCalledTimes(1);
82
+ client.stop();
83
+ });
41
84
  });